Compare commits

...

14 Commits

Author SHA256 Message Date
AidarKC
3a5856c7f0 Добавить кошелек блокчейна и озвучивание агента 2026-05-29 23:48:44 +04:00
AidarKC
775b655aac Логин guard: корректный precheck, company приоритет, hp в trademarks; подробные ошибки UI 2026-05-27 22:15:54 +04:00
AidarKC
101fd2eaa4 Solana-first регистрация: lazy-import пользователя при входе, AddUser отключен, UI ожидание 15с 2026-05-27 18:38:45 +04:00
AidarKC
6f0bb01b61 Промежуточный коммит: состояние до нормальной Solana-first регистрации 2026-05-27 18:33:26 +04:00
AidarKC
b345900459 docs: убрать выполненную задачу про кнопку создания канала из Future_Features 2026-05-26 00:46:58 +03:00
AidarKC
f1cfe9b6aa UI: обновлена шапка каналов, закрыты pending-задачи и обновлены версии 2026-05-26 00:30:49 +03:00
AidarKC
8941582d54 chore: зафиксированы все текущие изменения проекта 2026-05-25 23:46:54 +03:00
AidarKC
8c5de781ea API: задокументирован rawBlockB64 в GetMessageThread и обновлены версии 2026-05-25 23:43:43 +03:00
AidarKC
baef264bd0 Обновить формат Solana user PDA 2026-05-24 19:41:13 +03:00
AidarKC
74df7e2645 Добавить документацию Solana PDA и ESP32-подпроект 2026-05-24 19:29:42 +03:00
AidarKC
56cd90a197 Отключить репосты и добавить Solana-модуль 2026-05-24 12:16:39 +03:00
AidarKC
abdce05136 Удалить Java-реализацию агента-кодера 2026-05-24 09:30:25 +03:00
AidarKC
35565845ca Добавить канальный режим агента-кодера 2026-05-24 09:25:25 +03:00
AidarKC
a83ec2c971 Обновить сервис агента-кодера 2026-05-24 09:21:50 +03:00
223 changed files with 32968 additions and 3601 deletions

38
.gitignore vendored
View File

@ -50,3 +50,41 @@ bin/
# временный debug token # временный debug token
.debug-token .debug-token
# Локальные артефакты и секреты Solana-модуля
shine-solana/.git/
shine-solana/.git-local-backup/
shine-solana/.idea/
shine-solana/shine/.idea/
shine-solana/shine/.gradle/
shine-solana/shine/.anchor/
shine-solana/shine/.yarn/
shine-solana/shine/.vendor/
shine-solana/shine/node_modules/
shine-solana/shine/target/
shine-solana/shine/test-ledger/
shine-solana/shine/old_vers/
shine-solana/shine/program-keypair.json
shine-solana/shine/keys/
shine-solana/shine/validator.log
shine-solana/shine/doc/КОШЕЛЬКИ_DEVNET_ТЕСТ.md
shine-solana/shine/scripts/del/
shine-solana/shine/scripts/**/keypairs/
shine-solana/shine/scripts/**/runs/
shine-solana/shine/scripts/**/*.env
shine-solana/shine/scripts/**/TEMP_*.md
# Локальные артефакты и внешние материалы ESP32-подпроекта
ESP32/**/.git/
ESP32/**/.idea/
ESP32/**/.arduino-build/
ESP32/**/official-demo/
ESP32/**/original-firmware/*.bin
ESP32/**/original-firmware/*.bin.sha256
ESP32/**/*.elf
ESP32/**/*.map
ESP32/**/*.merged.bin
ESP32/**/*.uf2
ESP32/**/*.o
ESP32/**/*.d
ESP32/**/*.a

2
.idea/vcs.xml generated
View File

@ -2,5 +2,7 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" /> <mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo" vcs="Git" />
<mapping directory="$PROJECT_DIR$/shine-solana" vcs="Git" />
</component> </component>
</project> </project>

View File

@ -11,7 +11,23 @@
## Сервис агента-кодера ## Сервис агента-кодера
- В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`. - В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту. - Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
- Подробные правила работы сервиса, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`. - Автоматически читаемые инструкции для Codex внутри сервиса держать в `SHiNE-agent-bot-coder/AGENTS.md`.
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
- Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`.
## Solana-модуль
- В проекте есть отдельный Solana/Anchor-модуль в папке `shine-solana/shine/`.
- Модуль логически связан с SHiNE, но не должен автоматически подключаться к сборке или деплою основного сервера без отдельного решения.
- В Solana-модуле действуют локальные инструкции `shine-solana/shine/AGENTS.md`; при изменениях внутри модуля сначала читать их.
- В git добавлять исходники, lock-файлы, настройки проекта и документацию Solana-модуля, но не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
- Для Solana deploy/push использовать правила из локального `shine-solana/shine/AGENTS.md`; не смешивать deploy Solana-модуля с `deployServer`/`deployUI` основного проекта.
- Для регистрации пользователей в Solana (программа `shine_users`) единая актуальная инструкция по деплою/инициализации, адресам программ, и куда их прописывать в UI/сервере находится в:
- `Dev_Docs/Инициализация_Solana_регистрации/README.md`
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
- `Dev_Docs/Solana_Architecture/README.md`
- Документ формата пользовательской PDA-записи `shine_users` находится в:
- `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md`
## Документация блокчейна ## Документация блокчейна
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`. - Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
@ -47,13 +63,14 @@
- `client.version` — версия клиентского UI. - `client.version` — версия клиентского UI.
- `server.version` — версия серверной части. - `server.version` — версия серверной части.
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное. - Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
- Обычные коммиты делать стандартным `git commit`; переменная `$GITEA_TOKEN` для коммитов не нужна и не используется.
## Deploy ## Deploy
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`. - Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
- Базовый целевой хост для деплоя по умолчанию: `player@93.170.12.154` (`shineup.me`). - Базовый целевой хост для деплоя по умолчанию: `player@93.170.12.154` (`shineup.me`).
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`). - Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке. - По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
- Для операций `git push` использовать токен из переменной окружения `$GITEA_TOKEN`. - Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`.
- Для серверного деплоя использовать один gradle task: `./gradlew deployServer`. - Для серверного деплоя использовать один gradle task: `./gradlew deployServer`.
- Для UI деплоя использовать один gradle task: `./gradlew deployUI`. - Для UI деплоя использовать один gradle task: `./gradlew deployUI`.
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`). - Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`).
@ -89,6 +106,18 @@
- После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять. - После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять.
- В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность. - В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность.
## Будущие фичи
- Папка для задач, сознательно отложенных на будущее: `Dev_Docs/Future_Features/`.
- Точка входа по планам: `Dev_Docs/Future_Features/README.md`.
- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/`.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `Dev_Docs/Future_Features/README.md`, затем при необходимости конкретные файлы из горизонтов.
- Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя.
- Если часть кода временно отключена или закомментирована, в файле будущей фичи подробно описывать:
- какие файлы и участки отключены;
- что осталось в коде как заготовка;
- какие документы нужно обновить при возврате;
- с какого сценария продолжать разработку.
## Коммуникация по новым задачам (обязательно) ## Коммуникация по новым задачам (обязательно)
- При получении нового задания сначала кратко пересказать задачу своими словами. - При получении нового задания сначала кратко пересказать задачу своими словами.
- До начала реализации задать недостающие уточняющие вопросы (если они есть). - До начала реализации задать недостающие уточняющие вопросы (если они есть).

2
CLAUDE.md Normal file
View File

@ -0,0 +1,2 @@
@AGENTS.md
@AGENT_DEBUG_RUNBOOK.md

View File

@ -1,14 +1,14 @@
# API для разработчиков: Регистрация пользователя # API для разработчиков: Регистрация пользователя
Этот файл описывает временный раздел API, связанный с заведением пользователя на сервере и проверкой, существует ли пользователь. Этот файл описывает раздел API, связанный с проверкой наличия пользователя на сервере и dev/test операциями.
Сейчас здесь три метода: Сейчас здесь три метода:
- `AddUser`временная серверная регистрация пользователя; - `AddUser`операция отключена (регистрация только через Solana);
- `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных; - `GetUser` — временная серверная проверка существования пользователя и чтение его базовых данных;
- `SearchUsers` — dev/test поиск логинов по префиксу. - `SearchUsers` — dev/test поиск логинов по префиксу.
Их логика пока вспомогательная и dev-oriented: сервер сам хранит эти данные локально и сам отвечает на existence-check. В будущем оба сценария должны быть заменены на нормальную работу напрямую через Solana, но пока этот контракт нужен клиентам для разработки и интеграции. Регистрация выполняется через Solana (`shine_users`). Сервер при входе может лениво импортировать пользователя из Solana PDA в локальную БД, если записи ещё нет.
## Статус документа ## Статус документа
@ -22,12 +22,7 @@
### Назначение ### Назначение
Временная регистрация локального пользователя на сервере. Операция отключена. Используется только как явный ответ клиентам старых версий.
Сервер:
- создаёт запись в `solana_users`;
- создаёт стартовое состояние в `blockchain_state`.
### Запрос ### Запрос
@ -46,29 +41,16 @@
} }
``` ```
### Успешный ответ ### Пример ответа
```json ```json
{ {
"op": "AddUser", "op": "AddUser",
"requestId": "reg-001", "requestId": "reg-001",
"status": 200, "status": 410,
"ok": true,
"payload": {
}
}
```
### Пример ошибки
```json
{
"op": "AddUser",
"requestId": "reg-001",
"status": 409,
"ok": false, "ok": false,
"error": "USER_ALREADY_EXISTS", "error": "ADD_USER_DISABLED",
"message": "Пользователь с таким login уже существует", "message": "Серверная регистрация AddUser отключена. Используйте регистрацию через Solana.",
"payload": { "payload": {
} }
} }
@ -76,14 +58,7 @@
### Специфические коды ошибок `AddUser` ### Специфические коды ошибок `AddUser`
- `400 / BAD_FIELDS` — не переданы обязательные поля регистрации. - `410 / ADD_USER_DISABLED` — серверная регистрация отключена, используйте Solana-first flow.
- `400 / BAD_BLOCKCHAIN_NAME``blockchainName` не соответствует формату `<login>-NNN`.
- `400 / BAD_KEY_FORMAT` — один из ключей не является корректным `Base64(32 bytes)`.
- `409 / USER_ALREADY_EXISTS` — пользователь с таким `login` уже есть.
- `409 / BLOCKCHAIN_ALREADY_EXISTS` — такой `blockchainName` уже занят.
- `409 / BLOCKCHAIN_STATE_ALREADY_EXISTS` — стартовое состояние blockchain уже существует.
- `501 / DB_ERROR` — ошибка БД при создании пользователя.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера.
--- ---
@ -95,9 +70,8 @@
Важно: Важно:
- это временное решение; - это server-side existence-check;
- позже клиент должен проверять existence/identity напрямую через Solana; - если пользователя нет в локальной БД, он может быть импортирован при авторизации из Solana PDA.
- на финальный production flow не стоит жёстко завязывать архитектуру клиента на `GetUser`.
### Запрос ### Запрос
@ -125,11 +99,24 @@
"blockchainName": "anya-001", "blockchainName": "anya-001",
"solanaKey": "BASE64_32_PUBLIC_KEY", "solanaKey": "BASE64_32_PUBLIC_KEY",
"blockchainKey": "BASE64_32_PUBLIC_KEY", "blockchainKey": "BASE64_32_PUBLIC_KEY",
"deviceKey": "BASE64_32_PUBLIC_KEY" "deviceKey": "BASE64_32_PUBLIC_KEY",
"serverLastGlobalNumber": 128,
"serverLastGlobalHash": "4f...ab",
"serverBlockchainSizeBytes": 45212,
"serverBlockchainSizeLimitBytes": 100000,
"serverBlocksCount": 129
} }
} }
``` ```
Дополнительные серверные поля в `GetUser`:
- `serverLastGlobalNumber` — номер последнего блока в пользовательском блокчейне на сервере;
- `serverLastGlobalHash` — hash последнего блока (hex-строка 64 символа);
- `serverBlockchainSizeBytes` — текущий размер пользовательского блокчейна на сервере в байтах;
- `serverBlockchainSizeLimitBytes` — текущий лимит размера блокчейна на сервере в байтах;
- `serverBlocksCount` — количество блоков в пользовательском блокчейне на сервере;
### Успешный ответ: пользователя нет ### Успешный ответ: пользователя нет
```json ```json
@ -209,7 +196,7 @@
## 4. Короткое резюме ## 4. Короткое резюме
- `AddUser`временная регистрация пользователя на сервере. - `AddUser`отключен (`410 / ADD_USER_DISABLED`).
- `GetUser`временная проверка существования пользователя на сервере. - `GetUser` — проверка существования пользователя на сервере.
- `SearchUsers` — временный поиск пользователей по префиксу. - `SearchUsers` — временный поиск пользователей по префиксу.
- И регистрация, и existence-check позже должны быть переведены на Solana. - Регистрация выполняется только через Solana.

View File

@ -73,6 +73,7 @@ ed25519/BASE64_PUBLIC_KEY
- `400 / EMPTY_LOGIN` — пустой `login`. - `400 / EMPTY_LOGIN` — пустой `login`.
- `400 / ALREADY_AUTHED` — по текущему соединению уже выполнена авторизация. - `400 / ALREADY_AUTHED` — по текущему соединению уже выполнена авторизация.
- `422 / UNKNOWN_USER` — пользователь с таким `login` не найден. - `422 / UNKNOWN_USER` — пользователь с таким `login` не найден.
- `501 / SOLANA_IMPORT_FAILED` — сервер не смог проверить/импортировать пользователя из Solana при lazy-import.
- `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера, если появится вне штатного сценария. - `500 / INTERNAL_ERROR` — непредвиденная внутренняя ошибка сервера, если появится вне штатного сценария.
--- ---

View File

@ -81,11 +81,12 @@
- `bad_signature`, `signature_verify_failed` - `bad_signature`, `signature_verify_failed`
- `prev_line_block_not_found`, `bad_prev_line_hash` - `prev_line_block_not_found`, `bad_prev_line_hash`
- `limit_exceeded` - `limit_exceeded`
- `repost_disabled` — репосты временно отключены до будущей реализации
- `internal_error` - `internal_error`
## 5. Какие блоки реально можно добавлять через `AddBlock` ## 5. Какие блоки реально можно добавлять через `AddBlock`
Через `AddBlock` можно писать все поддержанные форматы: Через `AddBlock` можно писать поддержанные форматы, кроме явно отключённых временных фич:
1. **TECH (type=0)** 1. **TECH (type=0)**
- `HEADER_COMPAT (subType=0)` - `HEADER_COMPAT (subType=0)`
@ -96,7 +97,7 @@
- `TEXT_EDIT_POST (11)` - `TEXT_EDIT_POST (11)`
- `TEXT_REPLY (20)` - `TEXT_REPLY (20)`
- `TEXT_EDIT_REPLY (21)` - `TEXT_EDIT_REPLY (21)`
- `TEXT_REPOST (30)` - `TEXT_REPOST (30)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled`
3. **REACTION (type=2)** 3. **REACTION (type=2)**
- `REACTION_LIKE (1)` - `REACTION_LIKE (1)`

View File

@ -202,6 +202,13 @@
} }
``` ```
### MessageNode (дополнение)
- `MessageNode` расширяет формат сообщения из `GetChannelMessages` и дополнительно содержит:
- `channelInfo` — мета-информация о канале (если применимо);
- `rawBlockB64` — сырой `block_bytes` текущего блока в Base64.
- Поле `rawBlockB64` присутствует у узлов во всех частях ответа `GetMessageThread`: `focus`, `ancestors[]`, `descendants[]`.
- В `GetChannelMessages` поле `rawBlockB64` **не добавляется** (лента канала без сырого блока, чтобы не раздувать ответ).
--- ---
## 4) GetChannelsCounters ## 4) GetChannelsCounters

View File

@ -113,6 +113,11 @@
### GetMessageThread ### GetMessageThread
- `payload.ancestors[]`, `payload.focus`, `payload.descendants[]`. - `payload.ancestors[]`, `payload.focus`, `payload.descendants[]`.
- у узлов должны быть версии и счетчики. - у узлов должны быть версии и счетчики.
- у каждого узла дополнительно может приходить `rawBlockB64` (Base64 сырого `block_bytes`).
### Важно по совместимости
- `rawBlockB64` добавлен только в `GetMessageThread`.
- `GetChannelMessages` не содержит `rawBlockB64` (без изменений формата ленты).
--- ---

View File

@ -12,8 +12,8 @@
| Операция | Раздел документации | Кратко | | Операция | Раздел документации | Кратко |
| --- | --- | --- | | --- | --- | --- |
| `AddUser` | `01_User_Registration_API.md` | временная регистрация пользователя | | `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) |
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя | | `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна |
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу | | `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии | | `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |

View File

@ -24,7 +24,8 @@ TEXT-тип хранит сообщения и редактирования.
5. `subType=30``TEXT_REPOST` 5. `subType=30``TEXT_REPOST`
- репост сообщения в линию канала; - репост сообщения в линию канала;
- содержит line-поля + target на оригинальное сообщение + текст комментария; - содержит line-поля + target на оригинальное сообщение + текст комментария;
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются). - на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются);
- временно отключён для записи через `AddBlock` до будущей реализации репостов.
## Правило для edit ## Правило для edit

View File

@ -1,5 +1,11 @@
# История изменений документации блокчейна # История изменений документации блокчейна
## 2026-05-24 11:40:00 +0300
- Базовый коммит-ориентир: `abdce05`.
- `TEXT_REPOST (subType=30)` оставлен как зарезервированный формат, но новые блоки репоста временно отключены на уровне `AddBlock`.
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
- В `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`.
## 2026-05-21 19:05:00 +0300 ## 2026-05-21 19:05:00 +0300
- Базовый коммит-ориентир: `5344c42`. - Базовый коммит-ориентир: `5344c42`.
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`: - Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:

View File

@ -0,0 +1,42 @@
# Будущие фичи
Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку без отдельной команды пользователя.
## Горизонты планирования
- `near/` - ближайшие планы: задачи, к которым можно вернуться сегодня или завтра.
- `medium/` - среднесрочные планы: задачи на ближайшие недели или 1-2 месяца.
- `far/` - дальнее будущее: идеи без понятного срока возврата.
Если пользователь спрашивает, какие есть планы, агент должен смотреть эти три папки и кратко перечислять задачи по горизонтам.
## Как использовать
1. Каждая будущая фича описывается отдельным markdown-файлом в одном из горизонтов.
2. В файле нужно фиксировать:
- зачем нужна фича;
- к какому сроку или горизонту она относится;
- что нужно сделать;
- какие вопросы нужно уточнить перед реализацией;
- что уже было сделано в коде, если фича частично реализована;
- что временно отключено или закомментировано, если применимо;
- какие документы нужно обновить при возврате к задаче;
- с какого места продолжать разработку.
3. Агент не должен начинать реализацию файлов из этой папки без явной просьбы пользователя.
## Текущие планы
### Ближайшие
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
### Среднесрочные
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
### Дальнее будущее
- Сейчас задач нет.

View File

@ -0,0 +1,5 @@
# Дальнее будущее
Сейчас в этом горизонте нет активных идей.
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.

View File

@ -0,0 +1,99 @@
# Репосты в каналах и тредах
- Статус:
`future`
- Горизонт:
`medium`
- Ориентир:
1-2 месяца
- Решение от 2026-05-24:
Репосты временно убраны из активной разработки. Фича уже была частично реализована, но не доведена до финальной проверки. Чтобы она не мешала запуску проекта, пользовательский вход в репосты отключён в UI, а сервер больше не принимает новые `TEXT_REPOST` через `AddBlock`.
## Что должна делать фича
Репост должен позволять взять сообщение из канала или треда и опубликовать его в один из своих каналов с комментарием.
Ожидаемый пользовательский сценарий после возврата к задаче:
1. Пользователь открывает сообщение в канале или треде.
2. Нажимает `Репост`.
3. Выбирает один из своих каналов.
4. Добавляет комментарий.
5. Отправляет репост.
6. В целевом канале появляется новый пост-репост.
7. У репоста есть переход к исходному сообщению через действие `Оригинал`.
## Что уже есть в коде
- В блокчейн-формате зарезервирован и описан подтип `TEXT_REPOST (30)`.
- Парсер блокчейна умеет распознавать тело репоста как `TextLineBody`.
- В UI есть функция сборки тела репоста:
`shine-UI/js/services/auth-service.js`, `makeTextRepostBodyBytes`.
- В UI есть клиентская операция:
`shine-UI/js/services/auth-service.js`, `addBlockRepost`.
- В экране канала был обработчик репоста:
`shine-UI/js/pages/channel-view.js`, `onRepost`.
- В экране треда был обработчик репоста:
`shine-UI/js/pages/channel-thread-view.js`, `onRepost`.
- Серверная выдача каналов и тредов частично учитывает `TEXT_REPOST` и target-поля:
`targetBlockchainName`, `targetBlockNumber`, `targetBlockHash`.
- В списке подписок `TEXT_REPOST` считается публикацией канала.
## Что сейчас отключено
- В `shine-UI/js/pages/channel-view.js` кнопка `Репост` больше не создаётся и не добавляется в список действий сообщения.
- В `shine-UI/js/pages/channel-thread-view.js` кнопка `Репост` больше не создаётся и не добавляется в список действий ответа/сообщения треда.
- В `shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java` добавлена явная временная блокировка:
если новый блок имеет `type=1` и `subType=TEXT_REPOST (30)`, `AddBlock` возвращает ошибку `repost_disabled`.
## Что осталось активным намеренно
- Константа `TEXT_REPOST (30)` остаётся в коде и документации как зарезервированный формат.
- Парсер блокчейна продолжает знать формат `TEXT_REPOST`, чтобы не потерять уже написанную основу и не ломать потенциальное чтение старых тестовых данных.
- Код формирования репоста в `auth-service.js` не удалён: его можно будет использовать как основу при возвращении к задаче.
- Код отображения target-полей и перехода к оригиналу не удалён: он нужен для будущей проверки и возможной совместимости с уже созданными тестовыми блоками.
## Почему это не лежит в Pending_Features
`Dev_Docs/Pending_Features/` предназначена для фич, которые уже реализованы и ждут ручной проверки.
Репосты сейчас не подходят под этот статус: они не должны проверяться как готовая фича, потому что пользовательский сценарий временно закрыт, а серверная запись новых репостов заблокирована. Поэтому старый pending-файл удалён, а задача перенесена сюда как будущая.
## Что сделать при возврате к реализации
1. Решить, остаётся ли формат `TEXT_REPOST (30)` финальным.
2. Если формат меняется, заранее предупредить пользователя и получить отдельное подтверждение на изменение блокчейн-формата.
3. Вернуть UI-кнопки репоста в:
- `shine-UI/js/pages/channel-view.js`;
- `shine-UI/js/pages/channel-thread-view.js`.
4. Снять временную блокировку `repost_disabled` в:
`shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java`.
5. Проверить `auth-service.js`:
- `makeTextRepostBodyBytes`;
- `addBlockRepost`;
- актуализацию вершины блокчейна перед `AddBlock`;
- корректность target-полей исходного сообщения.
6. Проверить серверное чтение:
- `GetChannelMessages`;
- `GetMessageThread`;
- отображение `targetBlockchainName`, `targetBlockNumber`, `targetBlockHash`.
7. Добавить или обновить тесты на успешный репост и отказ некорректных target-полей.
8. Обновить документацию:
- `Dev_Docs/Blockchain/11_TEXT_Blocks.md`;
- `Dev_Docs/Blockchain/CHANGELOG.md`;
- `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`;
- документы API чтения каналов/тредов, если изменятся поля ответа.
9. После реализации перенести задачу из `Dev_Docs/Future_Features/` в `Dev_Docs/Pending_Features/` как фичу, требующую ручной проверки.
## Минимальный чек-лист ручной проверки в будущем
1. Репост из сообщения канала в свой канал.
2. Репост из ответа в треде в свой канал.
3. Ошибка при попытке репоста в чужой канал.
4. Переход `Оригинал` из репоста к исходному сообщению.
5. Корректное отображение комментария к репосту.
6. Корректная работа после перезагрузки страницы.
7. Отсутствие поломки обычных постов, ответов, лайков и отправки ссылки.

View File

@ -0,0 +1,62 @@
# Кошелёк и пополнение баланса сияния
- Горизонт:
`medium`
- Ориентир:
среднесрочно
- Статус:
`proposal`
## Кратко
Нужно добавить кошелёк для внутреннего баланса сияния и пополнение этого баланса через блокчейн-логику проекта. Задача связана с регистрацией пользователя и будущим учётом баланса.
## Предполагаемый сценарий
1. Пользователь регистрируется и получает/подключает нужные кошельки.
2. В интерфейсе появляется баланс сияния.
3. Пользователь открывает пополнение баланса сияния.
4. Система создаёт или принимает блокчейн-операцию пополнения.
5. После подтверждения баланса UI обновляет значение.
## Что нужно продумать
1. Что именно является единицей баланса сияния.
2. Где хранится состояние баланса: в существующем блокчейне SHiNE, Solana-модуле или комбинированно.
3. Какая операция отвечает за пополнение.
4. Нужно ли делать отдельную регистрацию кошелька сияния или использовать существующую регистрацию пользователя.
5. Как баланс восстанавливается после перезагрузки клиента.
6. Какие права нужны для пополнения и списания.
7. Нужна ли история операций баланса.
## Вопросы перед реализацией
1. Пополнение баланса сияния должно идти через основной блокчейн SHiNE или через Solana-программу.
2. Нужна ли конвертация из SOL/AR в сияние.
3. Кто может выпускать или начислять сияние.
4. Нужно ли поддерживать перевод сияния между пользователями.
5. Нужны ли лимиты, комиссии или статусы подтверждения.
6. Какой экран должен показывать баланс: регистрация, профиль, кошелёк или отдельная страница.
7. Нужно ли отображать неподтверждённый баланс отдельно от подтверждённого.
## Важное ограничение
Если для баланса сияния потребуется новый формат блокчейн-блока или изменение существующего формата, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение формата блокчейна.
Если потребуется новый серверный API или изменение существующих `op`, перед реализацией нужно отдельно предупредить пользователя и получить явное подтверждение на изменение API.
## Документы, которые обновить при реализации
- `Dev_Docs/Blockchain/`, если появятся или изменятся блоки баланса.
- `Dev_Docs/Blockchain/CHANGELOG.md`, если меняется блокчейн-формат.
- `Dev_Docs/API/`, если меняется серверный API.
- `Dev_Docs/Pending_Features/` - добавить файл ручной проверки после реализации.
- Документацию Solana-регистрации, если баланс будет связан с Solana-модулем.
## Минимальная проверка в будущем
1. Новый пользователь видит корректный начальный баланс.
2. Пополнение создаёт правильную операцию.
3. Баланс обновляется после подтверждения.
4. После перезагрузки UI баланс остаётся корректным.
5. Ошибочные или повторные операции не начисляют баланс дважды.

View File

@ -0,0 +1,44 @@
# ESP32S3 как личное файловое хранилище SHiNE
## Горизонт
Среднесрочный: ближайшие недели или 1-2 месяца.
## Зачем нужна фича
Нужно проработать маленький физический сервер на ESP32S3 как персональное или доверенное файловое хранилище SHiNE.
Идея: при обмене сообщениями пользователи смогут использовать такой сервер для хранения своих файлов, вложений, файлов общих переписок и связанных данных.
## Что нужно сделать
- Описать роль ESP32S3-сервера в общей архитектуре ключей и сессий.
- Определить, какие ключи может хранить такое устройство.
- Решить, хранит ли устройство только файлы или также подписывает пользовательские операции.
- Описать протокол загрузки, скачивания и удаления файлов.
- Определить правила шифрования файлов до отправки на устройство.
- Продумать индексацию файлов для личных и общих переписок.
- Решить, как устройство авторизуется на основном сервере SHiNE.
## Вопросы перед реализацией
- ESP32S3 должен работать как полностью локальное устройство или как публично доступный мини-сервер?
- Нужен ли внешний relay, если устройство находится за NAT?
- Какие ограничения по размеру файла считаем допустимыми?
- Хранит ли устройство метаданные переписок или только зашифрованные blob-файлы?
- Как восстанавливать доступ, если устройство потеряно или заменено?
## Что уже сделано
Код не реализован. Идея зафиксирована как будущая задача после описания модели ключей.
## Документы, которые нужно обновить при возврате
- `Dev_Docs/Keys/README.md`
- `Dev_Docs/Personal_Messages/README.md`
- `Dev_Docs/API/`
- `Dev_Docs/Blockchain/`, если появятся новые блоки или команды для файлов.
## С какого места продолжать
Начать с короткого протокольного документа: роли устройства, авторизация, шифрование файлов, минимальные API-операции и сценарии восстановления.

View File

@ -0,0 +1,94 @@
# Telegram-агент для разрешённых игроков
- Горизонт:
`near`
- Ориентир:
сегодня/завтра
- Статус:
`proposal`
## Кратко
Нужно расширить `SHiNE-agent-bot-coder`, чтобы агент мог принимать личные сообщения от заранее разрешённых пользователей, вести по каждому отдельную рабочую папку и историю, помогать им с обсуждениями/документами без изменения кода, а краткий результат публиковать в общий канал.
## Пользовательский сценарий
1. Разрешённый пользователь пишет агенту в личные сообщения текстом или голосом.
2. Голосовое сообщение распознаётся так же, как сейчас распознаются voice/audio-задачи.
3. Сервис определяет пользователя по разрешённому списку логинов.
4. Для пользователя используется отдельная папка в `Players/`.
5. Codex запускается с системным контекстом: от имени какого человека он работает, где лежит его папка, какие у него локальные инструкции.
6. Агент может читать код и документацию проекта, но писать должен только в папку этого пользователя, если нет отдельного согласования на изменение общего проекта.
7. После ответа пользователю агент отправляет в общий канал короткую сводку двумя сообщениями или двумя блоками: вопрос пользователя и полученный ответ.
8. Команда `/new` или `New` сбрасывает только сессию этого пользователя.
## Предлагаемая структура
- `Players/`
- `Ivan/`
- `AGENTS.md`
- `history/`
- `files/`
- `Sergey/`
- `AGENTS.md`
- `history/`
- `files/`
- `Milana/`
- `AGENTS.md`
- `history/`
- `files/`
Имена папок можно уточнить после получения точных Telegram-логинов.
## Что нужно сделать
1. Добавить конфигурацию разрешённых Telegram-пользователей.
2. Описать соответствие `telegram username -> имя игрока -> папка`.
3. Создавать или использовать отдельную историю диалога для каждого игрока.
4. Поддержать личные сообщения от разрешённых пользователей.
5. Запретить постановку задач от неизвестных пользователей.
6. Для групп/каналов оставить текущую логику: команды Айдара имеют приоритет.
7. При запуске Codex для игрока добавлять отдельный системный контекст:
- имя пользователя;
- путь к его папке;
- правило записи только в эту папку;
- путь к персональному `AGENTS.md`.
8. После ответа игроку отправлять краткую сводку в общий канал.
9. Поддержать `/new`/`New` как сброс только персональной сессии игрока.
10. Добавить защиту от случайного изменения общего кода в режиме игрока.
## Вопросы перед реализацией
1. Точные Telegram-логины Ивана, Сергея и Миланы.
2. Какой общий канал использовать для сводок: текущий `@shine_writing` или отдельный чат.
3. Нужно ли отправлять в общий канал полный текст вопроса/ответа или краткую выжимку.
4. Нужно ли пересылать вложения игроков в общий канал или только текстовые сводки.
5. Разрешить ли игрокам читать все документы проекта, включая технические заметки деплоя.
6. Что делать, если пользователь просит изменить код: отказать, создать предложение в своей папке или просить подтверждение Айдара.
7. Нужны ли русские имена папок (`Иван`, `Сергей`, `Милана`) или ASCII-имена (`Ivan`, `Sergey`, `Milana`).
8. Нужно ли хранить истории игроков в общей папке сервиса или внутри `Players/<name>/history/`.
## Риски и ограничения
- Нужно аккуратно разделить режим Айдара и режим игрока, чтобы игроки не могли случайно запустить изменение общего кода.
- Нужно не смешать истории разных пользователей.
- Нужно ограничить публикацию в общий канал, чтобы не утекали личные или слишком длинные ответы.
- Нужна проверка Telegram-идентификации: username может меняться, поэтому желательно хранить и `user_id`.
## Документы, которые обновить при реализации
- `SHiNE-agent-bot-coder/AGENTS.md`
- `SHiNE-agent-bot-coder/AGENT.md`
- `SHiNE-agent-bot-coder/README.md`
- `Dev_Docs/deploy/agent-bot-coder-local-systemd.md`, если появятся новые переменные окружения или настройки сервиса.
## Минимальная проверка
1. Айдар по-прежнему может ставить задачи из `@shine_writing`.
2. Неизвестный пользователь не ставит задачу в очередь.
3. Разрешённый игрок пишет личное текстовое сообщение и получает ответ.
4. Разрешённый игрок отправляет voice, оно распознаётся и обрабатывается.
5. История одного игрока не попадает в историю другого.
6. `/new` сбрасывает только историю текущего игрока.
7. Сводка вопрос/ответ появляется в общем канале.
8. В режиме игрока агент не пишет за пределы `Players/<name>/` без отдельного подтверждения.

View File

@ -0,0 +1,71 @@
# Пополнение Solana и Arweave через внешний сервис покупки
- Горизонт:
`near`
- Ориентир:
сегодня/завтра
- Статус:
`proposal`
## Кратко
Нужно добавить удобное пополнение кошельков на экране регистрации/кошелька: для Solana и Arweave дать отдельные действия `Пополнить`, которые ведут на международный сервис покупки криптовалюты с карты и помогают пользователю скопировать адрес кошелька.
## Пользовательский сценарий
1. Пользователь видит адрес кошелька Solana или Arweave.
2. Нажимает `Пополнить`.
3. Открывается промежуточное окно с инструкцией:
- сейчас пользователь перейдёт на страницу покупки/пополнения;
- нужно указать или проверить адрес кошелька;
- после оплаты нужно закрыть внешнюю страницу и вернуться назад;
- Solana обычно приходит быстро, ориентир 10-15 секунд после подтверждения сети;
- Arweave может идти дольше, точное время нужно уточнить по выбранному сервису.
4. В окне есть кнопки:
- `Скопировать адрес и перейти`;
- `Перейти без копирования`.
5. Для Solana и Arweave используются разные окна/инструкции и, возможно, разные внешние ссылки.
## Что нужно сделать
1. Найти текущий экран, где показываются кошельки при регистрации и пополнении.
2. Найти текущую ссылку покупки Arweave, если она уже есть в UI.
3. Выбрать международный сервис покупки Solana с карты, не российский.
4. Проверить, поддерживает ли сервис deep link с предзаполненным адресом кошелька.
5. Если deep link невозможен, реализовать промежуточное окно с копированием адреса.
6. Добавить отдельные действия для Solana и Arweave.
7. Сделать текст инструкции коротким и понятным.
8. Проверить, что адрес копируется в буфер обмена в браузере.
9. Проверить мобильный сценарий и desktop-сценарий.
## Вопросы перед реализацией
1. Какой сервис покупки Solana использовать: тот же провайдер, что для Arweave, или другой международный on-ramp.
2. Нужно ли разрешать покупку только SOL или также USDC/SPL-токены на Solana.
3. Где именно показывать кнопку `Пополнить`: только регистрация, настройки кошелька или оба места.
4. Нужно ли показывать предупреждение о комиссиях и стороннем сервисе.
5. Нужно ли открывать внешнюю страницу в новой вкладке или в текущем окне.
6. Нужно ли логировать факт нажатия `Пополнить` на сервере.
7. Какой точный текст использовать для времени прихода Arweave.
## Риски и ограничения
- On-ramp-сервисы меняют ссылки и параметры, поэтому deep link нужно проверять перед реализацией.
- Clipboard API может требовать HTTPS и пользовательский жест.
- Нельзя обещать точное время поступления средств: лучше писать ориентир и зависимость от сети/провайдера.
- Внешний сервис может быть недоступен в отдельных странах или для отдельных карт.
## Документы, которые обновить при реализации
- Документацию UI/кошельков, если такая есть.
- `Dev_Docs/Pending_Features/` - добавить файл ручной проверки после реализации.
- `Dev_Docs/API/`, только если появится новый серверный API или логирование.
## Минимальная проверка
1. На Solana-кошельке открывается правильное окно пополнения.
2. Кнопка `Скопировать адрес и перейти` копирует Solana-адрес и открывает внешний сервис.
3. Кнопка `Перейти без копирования` открывает внешний сервис без копирования.
4. Аналогичный сценарий работает для Arweave.
5. На мобильном экране текст и кнопки не перекрываются.
6. Возврат назад в приложение не ломает состояние регистрации/кошелька.

175
Dev_Docs/Keys/README.md Normal file
View File

@ -0,0 +1,175 @@
# Ключи SHiNE
Этот документ описывает роли ключей в SHiNE и их связь с Solana, персональным блокчейном, личными сообщениями, сессиями и будущими аппаратными устройствами.
Документ является архитектурной справкой. Он не меняет текущие форматы API, DM-блоков или блокчейна сам по себе.
## Коротко
В SHiNE у пользователя есть несколько уровней ключей:
- `root key` - главный корневой ключ пользователя, он же основной Solana-ключ.
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
- `device key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
## `root key`
`root key` - главный ключ пользователя.
Назначение:
- регистрация пользователя в Solana;
- создание и обновление пользовательской PDA-записи;
- вызов критически важных Solana-функций;
- изменение главных настроек пользователя;
- управление остальными ключами;
- подтверждение операций, которые должны иметь максимальный уровень доверия.
В текущей модели `root key` совпадает по смыслу с главным Solana-ключом пользователя.
На `root key` могут храниться значимые средства, если пользователь сознательно выбирает такую модель. Для мелких текущих расходов предпочтительнее использовать `device key`.
## `blockchain key`
`blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
Назначение:
- подпись записей в персональном блокчейне пользователя;
- подтверждение действий, которые должны попасть в SHiNE-блокчейн;
- разделение полномочий между главным Solana-ключом и ключом ежедневной записи.
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
- `username-001` - первая ветка;
- `username-002` - вторая ветка;
- `username-003` - третья ветка.
Рабочая логика по умолчанию должна использовать последнюю актуальную ветку. Старые ветки остаются читаемыми и показывают историю смены ключей.
## `device key`
`device key` - общий ключ, который знают доверенные устройства пользователя.
Назначение:
- повседневные входящие и исходящие личные сообщения;
- звонки и связанные с ними сообщения;
- self-messages, то есть внутренние сообщения пользователя самому себе;
- мелкие Solana-расходы на текущие операции;
- derivation Arweave-кошелька;
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
Arweave-кошелёк должен выводиться из `device key` по протоколу:
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md`
Если пользователь теряет только `device key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными.
## `session key`
`session key` - уникальный ключ конкретной сессии или устройства.
Возможные форматы:
- `Ed25519` - предпочтительный современный вариант;
- `RSA` - legacy-вариант, полезный для устройств, где системное защищённое хранилище хорошо поддерживает RSA-ключи и не позволяет извлекать приватный ключ.
Назначение:
- авторизация сессии на сервере;
- привязка устройства к пользователю;
- подтверждение запросов от конкретной сессии;
- доступ к зашифрованному `device key` после успешной авторизации.
Одна и та же сессия может быть пригодна для подключения к нескольким серверам пользователя, если архитектура конкретного пользователя это допускает.
У сессии должны быть:
- имя сессии;
- тип сессии;
- публичная часть ключа;
- ссылка на пользователя;
- информация о сервере или серверах, которым эта сессия доверена.
Имя сессии может создаваться автоматически из названия устройства и короткого случайного идентификатора, например `Android-a1b2c3`, `Ubuntu-f47a90`. Пользователь может переименовать сессию.
## Типы сессий
Базовые типы:
- обычная пользовательская сессия;
- серверная сессия;
- аппаратная или доверенная сессия с доступом к расширенным ключам.
Обычное устройство обычно имеет:
- собственный `session key`;
- зашифрованный `device key`, который открывается после авторизации;
- доступ к DM, звонкам и обычным пользовательским операциям.
Доверенное серверное или аппаратное устройство может иметь:
- `root key`;
- `blockchain key`;
- `device key`;
- собственный `session key`.
Такая сессия может подписывать операции повышенной важности по запросам пользователя.
## Внутренние self-messages
Self-message - это сообщение пользователя самому себе.
Такие сообщения нужны, чтобы обычное устройство могло попросить доверенное устройство выполнить действие:
- подписать запись `blockchain key` и передать её в SHiNE-блокчейн;
- подписать изменение настройки через `root key`;
- обновить ключи;
- сохранить внутреннюю команду или настройку;
- отправить сообщение другому пользователю с сохранением копии себе;
- сохранить сообщение только себе.
Важно: self-message не является публичной командой сервера. Это пользовательская внутренняя команда, которую сервер или доверенное устройство обрабатывает в рамках прав конкретного пользователя.
## Шифрование входящих сообщений
Входящее сообщение может быть зашифровано:
- `device key`;
- `session key`;
- отдельным ключом конкретного чата;
- другим ключом, который уже известен клиенту.
В сообщении не должно быть лишнего раскрытия того, каким именно ключом оно зашифровано. Клиент пробует расшифровать сообщение доступными ключами по порядку. Если расшифровка не удалась, сообщение остаётся непонятным для этого устройства.
## Копии сообщений
Для отправки сообщений нужны несколько режимов:
- сообщение другому пользователю с исходящей копией себе;
- сообщение другому пользователю без локальной исходящей копии;
- сообщение только себе.
Это должно позволить строить обычные DM, внутренние команды, личные заметки и зашифрованные пользовательские чаты поверх одной общей модели сообщений.
## Связанные документы
- `Dev_Docs/Personal_Messages/README.md` - текущая документация личных сообщений.
- `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
- `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств.
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации.
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `device key`.
## Что нужно уточнить перед реализацией
- точный формат записи списка ключей в Solana PDA;
- как именно обозначать активную ветку персонального блокчейна;
- какие операции требуют `root key`, а какие достаточно подписывать `blockchain key`;
- формат self-message-команд;
- порядок перебора ключей при расшифровке входящих сообщений;
- правила ротации `device key` и восстановления доступа после потери устройства;
- какие типы серверных и аппаратных сессий нужны в первой реализации.

View File

@ -1,21 +0,0 @@
# Репосты в каналах и тредах
- Краткое описание:
Добавлен подтип `TEXT_REPOST (30)` и UI-режим репоста с комментарием. Репост можно делать как из сообщения канала, так и из сообщения в треде. Для репоста выбирается один из своих каналов.
- Что проверять:
1. В канале открыть любое сообщение и нажать `Репост`.
2. Выбрать свой канал, ввести комментарий, отправить.
3. Убедиться, что в целевом канале появился новый пост-репост.
4. Нажать `Оригинал` у репоста и подтвердить переход.
5. Проверить, что переход открывает исходное сообщение.
6. Повторить сценарий из треда (для сообщения-ответа).
- Ожидаемый результат:
- Репост успешно записывается в блокчейн как `TEXT_REPOST`.
- В выдаче `GetChannelMessages`/`GetMessageThread` возвращаются поля target (`targetBlockchainName`, `targetBlockNumber`, `targetBlockHash`) для репоста.
- Кнопка `Оригинал` открывает нужное исходное сообщение.
- Для репоста не отображается история редактирования (одна версия).
- Статус:
`pending`

View File

@ -1,18 +0,0 @@
# SHiNE-agent-bot-coder: очередь, voice, codex, systemd
- краткое описание фичи:
Добавлен новый сервис `SHiNE-agent-bot-coder` (Java), который обрабатывает сообщения от `@AidarKC`, ведёт JSONL-историю, использует файловую очередь, распознаёт voice через OpenAI, вызывает Codex CLI и поддерживает запуск как `systemd`-сервис.
- что именно проверять:
1. Бот принимает текст от `@AidarKC`, ставит задачу в очередь и отправляет ответ от Codex.
2. Бот принимает voice, отправляет текст распознавания и затем ответ от Codex.
3. Одновременные сообщения обрабатываются строго по одному (без параллельных запусков Codex).
4. После рестарта сервиса незавершённая активная задача повторно уходит в обработку.
5. Команда `/new` архивирует текущую историю и создаёт новую.
6. `systemd`-сервис стартует и автоматически перезапускается.
- ожидаемый результат:
Все пункты выше отрабатывают без потери сообщений, с корректным обновлением `data/queue.jsonl`, `data/state.json` и `data/history/*.jsonl`.
- статус:
`pending`

View File

@ -1,22 +0,0 @@
# Агент-бот coder: устранение дублей и зависаний
- краткое описание фичи:
- добавлен lock-файл `data/app.lock`, чтобы гарантировать единственный инстанс бота;
- доработано завершение worker/codex при stop/restart, чтобы не было ложных retry после штатной остановки;
- синхронизирован systemd-профиль под `--user` для `ai`;
- улучшена отправка промежуточных статусов по событиям `codex --json`.
- что проверять:
- при запущенном сервисе повторный запуск jar вручную завершается сразу с сообщением про занятый lock;
- команда `/status` отвечает один раз (без дублей);
- тестовая текстовая задача от `@AidarKC` обрабатывается и возвращает ответ;
- при `/stop` активная задача завершается без последующего спама retry-ошибками;
- в логах нет `409 Conflict` из-за второго poller после чистого перезапуска.
- ожидаемый результат:
- одновременно работает только один процесс бота;
- бот не дублирует ответы;
- очередь не застревает на interrupted после штатного stop/restart.
- статус:
- pending

View File

@ -1,21 +0,0 @@
# Python-обвязка Telegram → Codex (упрощённый сервис)
- краткое описание фичи:
- добавлен новый упрощённый сервис `SHiNE-agent-bot-coder/py_bot_service.py`;
- сервис работает через long-polling Telegram, принимает только текст, ведёт историю в `JSONL`;
- добавлены команды `/status`, `/queue`, `/stop`, `/cancel`, `/new`, `/help`;
- systemd unit переключён на запуск Python-сервиса.
- что проверять:
- `systemctl --user status shine-agent-bot-coder` показывает `active (running)`;
- бот отвечает на `/status` одним сообщением без дублей;
- тестовый текстовый запрос получает финальный ответ от Codex;
- `/stop` корректно останавливает текущую задачу;
- `/new` переносит текущую историю в `data/history/archive`.
- ожидаемый результат:
- стабильная обработка текстовых задач без зависаний и двойной обработки;
- только один инстанс сервиса (через lock `data/py_app.lock`).
- статус:
- pending

View File

@ -0,0 +1,43 @@
# Кошелёк: лимит/закрепление блокчейна Сияния
- статус: `pending`
## Кратко что сделано
- На экране `Кошелёк -> Блокчейн Сияния` добавлены 2 слоя данных:
- фактическое состояние цепочки на сервере (`кол-во блоков`, `размер`, `крайний блок`, `hash`, `размер крайнего блока`);
- закреплённое состояние в Solana PDA (`лимит`, `использовано`, `остаток`, `крайний блок`, `hash`).
- Добавлены действия:
- `Закрепить в Solana` — обновляет PDA до текущего состояния серверной цепочки;
- `Увеличить лимит` — увеличивает `paid_limit_bytes` в PDA с учётом цены из economy PDA.
- Если `rootKey`/`blockchainKey` не сохранены локально, экран запрашивает пароль, восстанавливает ключи через стандартную derivation-логику и предлагает сохранить их в зашифрованный контейнер.
## Что проверять вручную
1. Открыть `Кошелёк -> Блокчейн Сияния` под авторизованным пользователем.
2. Проверить, что в блоке "Фактическое состояние на сервере" отображаются:
- число блоков;
- размер цепочки;
- номер/хэш крайнего блока;
- размер крайнего блока.
3. Проверить, что в блоке "Закреплено в Solana" отображаются:
- лимит;
- израсходовано;
- остаток;
- номер/хэш крайнего закреплённого блока.
4. Нажать `Закрепить в Solana` и убедиться, что:
- приходит успешная транзакция;
- после обновления Solana-показатели подтягиваются до серверных (или максимально близко по актуальному состоянию).
5. Нажать `Увеличить лимит`, ввести значение кратное шагу, подтвердить списание и проверить:
- лимит увеличился;
- отображение цены/списания соответствует economy PDA.
6. Повторить пункты 4-5 в сценарии, когда `rootKey`/`blockchainKey` не сохранены, и проверить:
- появляется запрос пароля;
- после ввода пароля операции выполняются;
- предложение сохранить ключи показывается.
## Ожидаемый результат
- Экран корректно разделяет "фактическое состояние на сервере" и "закреплённое в Solana".
- Обе операции (`Закрепить в Solana`, `Увеличить лимит`) выполняются без ошибок при валидных данных.
- Восстановление ключей через пароль работает, а без нужных ключей операция не выполняется молча.

View File

@ -0,0 +1,29 @@
# Озвучивание ответов агента
## Что сделано
В локальный Telegram-бот-сервис агента-кодера добавлены персональные настройки озвучивания финальных ответов:
- `/voice_on` включает озвучивание для текущего Telegram-пользователя;
- `/voice_off` выключает озвучивание для текущего Telegram-пользователя;
- `/voice_status` показывает текущее состояние;
- если озвучивание включено, после текстового финального ответа сервис генерирует voice-файл через OpenAI TTS и отправляет его в Telegram;
- длинные ответы делятся на несколько фрагментов озвучки.
## Что проверять
1. Перезапустить `shine-agent-bot-coder`.
2. Отправить `/voice_status` и убедиться, что по умолчанию озвучивание выключено.
3. Отправить `/voice_on`.
4. Дать простую задачу агенту и проверить, что пришёл полный текстовый ответ и voice-файл с тем же ответом.
5. Отправить `/voice_off`.
6. Дать ещё одну простую задачу и проверить, что приходит только текст.
7. При возможности проверить второго whitelist-пользователя: его настройка должна быть независимой.
## Ожидаемый результат
Настройка хранится персонально по username и сохраняется после перезапуска сервиса. При включённой настройке Telegram получает текстовый ответ и дополнительное voice-сообщение с озвучкой. При выключенной настройке поведение остаётся прежним.
## Статус
pending

View File

@ -0,0 +1,23 @@
# Solana user_pda v2
## Краткое описание
Функции `create_user_pda` и `update_user_pda` в Solana-модуле переведены на блочный формат пользовательской PDA-записи `format_major = 2`.
## Что проверять
- Создание `user_pda` через `create_user_pda`.
- Обновление `user_pda` через `update_user_pda`.
- Проверку root-подписи записи.
- Проверку подписи `LastBlockState` ключом `blockchain_public_key`.
- Корректную запись блоков `RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `TrustedState`.
- Рост `paid_limit_bytes`, `used_bytes` и `last_block_number` без возможности уменьшения.
- Совместимость тестового клиента с актуальной IDL после `anchor build`.
## Ожидаемый результат
Пользовательская PDA создается и обновляется в формате `format_major = 2`, содержит один основной блокчейн `blockchain_type = 1` с именем `<login>-001`, а неверные подписи или попытки уменьшить счетчики отклоняются программой.
## Статус
pending

View File

@ -0,0 +1,26 @@
# Solana: init регистрации + деплой обязательных программ
- дата: 2026-05-24 20:35 (Europe/Moscow)
- статус: `pending`
## Кратко
Добавлена dev-страница в UI для вызова `init_users_economy_config` программы `shine_users` через подключённый кошелёк Phantom.
Задеплоены и зафиксированы адреса двух обязательных программ регистрации: `shine_users` и `shine_login_guard`.
## Что проверять вручную
1. Открыть UI и перейти в `Настройки разработчика`.
2. Нажать `Solana: init регистрации`.
3. Подключить Phantom devnet-кошелёк.
4. Выполнить `init_users_economy_config`.
5. Проверить отображение статуса и хэша транзакции.
6. Повторно нажать init и убедиться, что корректно показывается "уже инициализировано".
7. Выполнить тестовую регистрацию пользователя и убедиться, что CPI-вызов `shine_login_guard` не падает.
## Ожидаемый результат
- Первая транзакция выполняется успешно (если PDA ещё не создан).
- Вторая попытка возвращает ожидаемую ошибку о повторной инициализации.
- UI не падает, статус понятный, Program ID отображается корректно.
- Регистрация пользователя проходит с подключённым `shine_login_guard`.

View File

@ -0,0 +1,26 @@
# Отчёт private-запросов агента в группу
## Что сделано
После успешной обработки задачи из личного чата Айдара Telegram-бот агента отправляет итоговую копию в группу `@shine_writing`:
- первым сообщением исходный запрос;
- вторым сообщением, reply на первое, финальный ответ Codex.
Промежуточные статусы выполнения в группу не дублируются.
## Что проверять
1. Отправить боту личный текстовый запрос.
2. Дождаться полного ответа в личном чате.
3. Проверить, что в `@shine_writing` появилось сообщение с запросом.
4. Проверить, что итоговый ответ опубликован reply на это сообщение.
5. Отправить личный voice-запрос и проверить, что в отчёте есть распознанный текст.
## Ожидаемый результат
Личный чат работает как раньше, а группа получает только итоговую пару сообщений по завершённой задаче.
## Статус
pending

View File

@ -0,0 +1,23 @@
# Отчёт voice/audio-запросов с исходным файлом
## Краткое описание
Публичный отчёт по приватным voice/audio-запросам агента должен отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. `file_id` не должен показываться пользователям в тексте отчёта.
## Что проверить
1. Отправить боту приватный voice-запрос от Айдара.
2. Дождаться обработки Codex.
3. Проверить группу/канал публичных отчётов.
4. Повторить сценарий для audio-файла, если он используется.
## Ожидаемый результат
- В публичном отчёте появляется исходное голосовое/audio-сообщение.
- В подписи к нему есть распознанный текст.
- В отчёте нет строки `Голосовой file_id` и самого `file_id`.
- Итоговый ответ Codex отправляется ответом на сообщение с исходным файлом.
## Статус
pending

View File

@ -0,0 +1,19 @@
# Улучшенная обработка длинных voice/audio
## Что сделано
- Увеличены и вынесены в `.env` тайм-ауты скачивания Telegram-файла и OpenAI-распознавания.
- Добавлены подробные логи стадий распознавания: старт, скачивание, размер файла, завершение, причина ошибки.
- Ошибки voice/audio теперь показываются пользователю как ошибка распознавания с понятной причиной.
## Как проверять
- Отправить короткое голосовое сообщение и убедиться, что оно распознаётся и передаётся в Codex.
- Отправить длинное голосовое сообщение и убедиться, что сервис не падает на прежнем коротком тайм-ауте.
- Смоделировать ошибку распознавания или временно указать неверный OpenAI-ключ и проверить текст ошибки в Telegram.
## Ожидаемый результат
- При успешном распознавании пользователь видит распознанный текст и задача уходит в Codex.
- При ошибке пользователь видит, что не удалось именно распознать voice/audio, и получает конкретную причину.
- В логах сервиса видны стадия и техническая причина сбоя.
## Статус
pending

View File

@ -0,0 +1,365 @@
# Solana user_pda: итоговый целевой формат пользовательской записи
Документ описывает целевой формат пользовательской PDA-записи `user_pda` для Solana-программы `shine_users`.
Это не формат основного блокчейна SHiNE и не документация по `AddBlock`. Основной блокчейн SHiNE описан отдельно в `Dev_Docs/Blockchain/`.
Статус документа: итоговый согласованный формат, к которому приведены `create_user_pda`, `update_user_pda` и тестовый сериализатор Solana-модуля.
## 1. Назначение user_pda
`user_pda` хранит публичное состояние пользователя в Solana:
- логин пользователя;
- неизменяемые параметры создания записи;
- корневой публичный ключ пользователя;
- ключ устройства;
- данные одного или нескольких пользовательских блокчейнов SHiNE;
- серверные данные пользователя, если пользователь выступает сервером;
- серверы доступа пользователя;
- счетчики/лимиты;
- подпись записи.
На первом этапе поддерживается один пользовательский блокчейн SHiNE, но формат блока блокчейна сразу допускает повторение таких блоков в будущем.
## 2. Адрес PDA
Адрес пользовательской PDA вычисляется по логину:
- seed prefix: `login=`;
- второй seed: нормализованный логин в нижнем регистре;
- program id: программа `shine_users`.
Один логин соответствует одной `user_pda`.
## 3. Общие правила кодирования
- Числа кодируются в Little Endian.
- `u8`, `u16`, `u32`, `u64` имеют обычный фиксированный размер.
- Публичный ключ Solana/Ed25519: 32 байта.
- Ed25519-подпись: 64 байта.
- SHA-256/Solana hash: 32 байта.
- Строка переменной длины: `len: u8` + `bytes[len]` в UTF-8.
- Arweave `tx_id`: строка переменной длины. Ожидаемая практическая длина base64url tx id - 43 байта, но формат хранит длину явно.
- Все типизированные блоки после фиксированного заголовка начинаются с `block_type: u8` и `block_version: u8`.
- Отдельный `block_len` у типизированных блоков не хранится: блоки парсятся по известным полям, счетчикам и строкам с `len: u8`.
## 4. Верхний формат записи
Первые 9 полей фиксированы и идут строго в указанном порядке. Это общий заголовок записи.
| N | Поле | Тип | Размер | Правило |
|---|------|-----|--------|---------|
| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. |
| 2 | `format_major` | `u8` | 1 | Для первого формата: `1`. |
| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. |
| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. |
| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. |
| 6 | `updated_at_ms` | `u64` | 8 | Время последнего обновления записи. |
| 7 | `record_number` | `u32` | 4 | Номер версии записи пользователя. При создании `0`, при обновлении +1. |
| 8 | `prev_record_hash` | bytes | 32 | Хэш unsigned-части предыдущей записи. При создании 32 нулевых байта. |
| 9 | `login` | string | `1 + len` | Логин пользователя. Не меняется. |
После первых 9 полей идет набор типизированных блоков:
```text
UserPdaRecordV1
- fixed_header: поля 1..9
- blocks_count: u8
- blocks: TypedBlock[blocks_count]
- signature: [u8; 64]
- padding: bytes до размера PDA, если нужен
```
`blocks_count` входит в unsigned-часть записи и подписывается.
## 5. Типы блоков
Зарезервированные значения `block_type`:
| block_type | Блок | Назначение |
|------------|------|------------|
| `1` | `RootKeyBlock` | Корневой ключ пользователя. |
| `2` | `DeviceKeyBlock` | Ключ устройства пользователя. |
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
| `30` | `ServerProfileBlock` | Серверные данные пользователя. |
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `50` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
Правила:
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `TrustedStateBlock`;
- каждый обязательный блок должен встречаться ровно один раз;
- порядок блоков в записи фиксируется для простоты проверки:
`RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `TrustedState`.
## 6. RootKeyBlock
Смена `root_key` пока не проектируется и не реализуется. Блок фиксирует только стадию `0`.
```text
RootKeyBlock
- block_type: u8 = 1
- block_version: u8 = 0
- root_key: [u8; 32]
```
Правила:
- при создании задается корневой публичный ключ пользователя;
- при обновлении `root_key` должен совпадать с предыдущей записью;
- ротация root-key будет отдельным форматом/сценарием в будущем.
## 7. DeviceKeyBlock
Смена `device_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один ключ устройства.
```text
DeviceKeyBlock
- block_type: u8 = 2
- block_version: u8 = 0
- device_key: [u8; 32]
```
Правила:
- при создании задается текущий публичный ключ устройства;
- при обновлении ключ устройства может быть обновлен только если это отдельно разрешено бизнес-логикой инструкции;
- история устройств и несколько устройств в этом формате не хранятся.
## 8. BlockchainRegistryBlock
Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список.
```text
BlockchainRegistryBlock
- block_type: u8 = 3
- block_version: u8 = 0
- blockchain_count: u8
- blockchain_records: BlockchainRecord[blockchain_count]
```
Правила:
- на первом этапе `blockchain_count = 1`;
- в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`;
- каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн.
## 9. BlockchainRecord
```text
BlockchainRecord
- blockchain_type: u8
- blockchain_name: string
- blockchain_public_key: [u8; 32]
- paid_limit_bytes: u64
- used_bytes: u64
- last_block_number: u32
- last_block_hash: [u8; 32]
- last_block_signature: [u8; 64]
- arweave_present: u8
- arweave_tx_id: string, только если arweave_present = 1
```
`blockchain_type`:
| Значение | Смысл |
|----------|-------|
| `1` | Основной пользовательский SHiNE-блокчейн. |
Поля:
- `blockchain_name` - строковое имя пользовательского блокчейна, например `login-001`. На первом этапе для основного блокчейна пользователя используется имя вида `<login>-001`, потому что это первый блокчейн этого пользователя.
- `blockchain_public_key` - публичный ключ блокчейна пользователя.
- `paid_limit_bytes` - оплаченный лимит хранения/записей в байтах.
- `used_bytes` - сколько байт уже занято в пользовательском SHiNE-блокчейне.
- `last_block_number` - номер последнего известного блока пользовательского блокчейна.
- `last_block_hash` - хэш последнего известного блока.
- `last_block_signature` - подпись хэша специального сообщения о вершине блокчейна ключом `blockchain_public_key`.
- `arweave_present` - `0`, если ссылки нет; `1`, если ссылка есть.
- `arweave_tx_id` - Arweave transaction id, где лежит выгруженный пользовательский канал/состояние.
Arweave `tx_id` - обычное поле внутри записи конкретного блокчейна. Solana-программа не проверяет, что такой Arweave transaction действительно существует и содержит корректные данные; это ответственность клиента/сервера/пользователя.
## 10. Правила обновления BlockchainRecord
При обновлении записи:
- `blockchain_type` для существующей записи не меняется;
- `blockchain_public_key` пока не ротируется автоматически; смена ключа требует отдельного согласованного сценария;
- `paid_limit_bytes` может только увеличиваться или оставаться прежним;
- при увеличении `paid_limit_bytes` пользователь платит комиссию в Solana по тарифам программы;
- `used_bytes` может только увеличиваться или оставаться прежним;
- `last_block_number` может только увеличиваться или оставаться прежним;
- `used_bytes <= paid_limit_bytes`;
- если `last_block_number` увеличился, то должны быть переданы новый `last_block_hash` и новая `last_block_signature`;
- `last_block_signature` проверяется через Ed25519-инструкцию Solana: подпись должна соответствовать хэшу сообщения `LastBlockState` и `blockchain_public_key`;
- `arweave_tx_id` можно добавить или заменить на новый, если пользователь выгрузил более актуальное состояние в Arweave;
- уменьшать лимит, число блоков или занятый размер нельзя.
Сообщение `LastBlockState`, которое хэшируется и подписывается ключом `blockchain_public_key`:
```text
LastBlockState
- constant: bytes = "SHiNE_LAST_BLOCK"
- login: string
- blockchain_name: string
- last_block_number: u32
- last_block_hash: [u8; 32]
- used_bytes: u64
```
Алгоритм:
```text
message = SHA-256(LastBlockState bytes)
last_block_signature = Ed25519(blockchain_public_key, message)
```
Причина проверки подписи `LastBlockState`: `root_key` управляет Solana-записью пользователя, а `blockchain_public_key` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера.
## 11. ServerProfileBlock
Блок присутствует, если пользователь выступает сервером.
```text
ServerProfileBlock
- block_type: u8 = 30
- block_version: u8 = 0
- is_server: u8
- server_key: [u8; 32], только если is_server = 1
- server_address: string, только если is_server = 1
- sync_servers_count: u8, только если is_server = 1
- sync_servers: string[sync_servers_count], только если is_server = 1
```
Правила:
- `is_server = 0` означает, что серверных данных нет;
- `is_server = 1` означает, что пользователь публикует серверный профиль;
- `sync_servers_count` максимум `32`;
- `server_address` - строковый адрес сервера в формате, который будет отдельно закреплен на уровне приложения;
- `sync_servers` - логины пользователей системы, через которых этот сервер пытается синхронизироваться. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы.
## 12. AccessServersBlock
Блок хранит серверы доступа/relay для пользователя.
```text
AccessServersBlock
- block_type: u8 = 40
- block_version: u8 = 0
- access_servers_count: u8
- access_servers: string[access_servers_count]
```
Правила:
- блок может отсутствовать, если серверы доступа не заданы;
- список может обновляться при изменении маршрутизации пользователя;
- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы;
- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE.
## 13. TrustedStateBlock
Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик.
```text
TrustedStateBlock
- block_type: u8 = 50
- block_version: u8 = 0
- trusted_count: u8 = 0
```
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
## 14. Подпись user_pda
Подписывается не вся PDA целиком, а unsigned-часть записи:
- от `magic` до последнего байта последнего типизированного блока включительно;
- включая `record_len`, `blocks_count`, все заголовки блоков и тела блоков;
- без поля `signature`;
- без padding.
Алгоритм:
```text
message = hash(unsigned_record_bytes)
signature = Ed25519(root_key, message)
```
Solana-программа проверяет подпись через встроенную Ed25519-инструкцию. Подписантом должен быть `root_key` из `RootKeyBlock`.
Смену формата подписи сейчас не трогаем.
## 15. Регистрация пользователя
При регистрации:
- PDA еще не должна существовать;
- логин проходит проверку формата и login guard;
- `record_number = 0`;
- `prev_record_hash = 0x00...00`;
- `created_at_ms = updated_at_ms`;
- обязательные блоки присутствуют;
- создается минимум один `BlockchainRecord`;
- стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит;
- `used_bytes <= paid_limit_bytes`;
- пользователь платит регистрационную комиссию;
- если покупается дополнительный лимит, пользователь платит комиссию за этот лимит;
- вся unsigned-часть записи подписана `root_key`.
## 16. Обновление пользователя
При обновлении:
- PDA должна существовать;
- `login`, `created_at_ms`, `root_key` не меняются;
- `record_number = previous_record_number + 1`;
- `prev_record_hash` равен хэшу unsigned-части предыдущей записи;
- `updated_at_ms` обновляется;
- unsigned-часть новой записи подписана `root_key`;
- лимиты блокчейнов могут только увеличиваться;
- занятый размер и номер последнего блока не могут уменьшаться;
- при увеличении оплаченного лимита пользователь доплачивает комиссию;
- Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует.
## 17. Отличия от старого линейного формата
Старый формат после `login` хранил поля линейно:
- `root_key_status`;
- `root_key`;
- `blockchain_key_status`;
- `blockchain_key`;
- `device_key_status`;
- `device_key`;
- `chain_number`;
- `balance`;
- серверные поля;
- access-серверы;
- `trusted_count`;
- `reserved`;
- `signature`.
Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки:
- ключи становятся отдельными блоками;
- данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`;
- серверные данные и access-серверы отделяются от данных блокчейна;
- расширение формата делается добавлением новых версий блоков или новых `block_type`, а не вставкой полей в середину линейной записи.
## 18. Что пока не входит в формат
Пока не проектируем:
- ротацию `root_key`;
- сложную ротацию `device_key`;
- ротацию `blockchain_public_key`;
- проверку содержимого Arweave transaction;
- хранение полной истории пользовательского блокчейна внутри Solana;
- подключение Solana-модуля к сборке/деплою основного сервера SHiNE.

View File

@ -0,0 +1,166 @@
# Архитектура Solana-программ SHiNE
Документ описывает рабочую архитектуру Solana-части SHiNE: три Anchor-программы, DAO, ключи управления, PDA-счета и движение денег.
Это архитектурная справка. Она не меняет код, формат PDA-записи пользователя, серверный API или формат блокчейна SHiNE.
Статус: актуализировано по коду `shine-solana/shine/programs/*` на 2026-05-25.
Связанные документы:
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` — single source of truth по деплою и первичной инициализации регистрации пользователей.
- `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md` — точный формат `user_pda` для `shine_users`.
- `shine-solana/shine/doc/FUNDS_FLOW.md` — короткая справка по денежным потокам внутри Solana-модуля.
## Кратко
В Solana-модуле сейчас три основные программы:
1. `shine_login_guard` — проверяет логин и возвращает класс логина: обычный, premium или trademark.
2. `shine_users` — создает и обновляет пользовательскую PDA-запись, проверяет подписи и берет оплату за регистрацию/увеличение лимита.
3. `shine_payments` — принимает входящий поток средств в `inflow_vault`, ведет очереди тикетов, позволяет DAO выдавать лимиты менеджерам и выполняет выплаты.
DAO в текущем виде не является отдельной Anchor-программой SHiNE внутри `programs/`. Это управляющая модель поверх кошельков, governance-скриптов и authority-адресов. Для проектирования ее удобно считать отдельным управляющим блоком: DAO голосует, назначает управляющие ключи, управляет казной и вызывает защищенные методы второй и третьей программ.
## Общая схема
Редактируемая Mermaid-схема находится в [schemes/architecture.mmd](schemes/architecture.mmd).
Картинки:
- [schemes/architecture.svg](schemes/architecture.svg)
- [schemes/architecture.png](schemes/architecture.png)
## Программы и функции
| Блок | Папка/имя | Текущие функции из кода | Основной смысл |
| --- | --- | --- | --- |
| 1 | `shine_login_guard` | `classify_login` | Проверка логина перед регистрацией. |
| 2 | `shine_users` | `init_users_economy_config`, `update_users_economy_config`, `create_user_pda`, `update_user_pda` | Регистрация пользователя, обновление записи, экономика лимита. |
| 3 | `shine_payments` | `init`, `update_coef_limit`, `grant_manager_limits`, `buy_ticket`, `buy_ticket_usd`, `buy_ticket_sol`, `manager_add_ticket`, `step_payout`, `change_ticket_recipient` | Vault, билеты, очереди, выплаты, DAO-настройки, лимиты менеджеров. |
| DAO | governance/authority | Вызовы через governance и управляющие ключи | Управление правами, казной, настройками и будущими обновлениями программ. |
## Актуальные program id
Актуальные адреса заданы одновременно в `Anchor.toml`, `declare_id!` программ и `programs/common/src/deploy_config.rs`:
| Программа | Program ID |
| --- | --- |
| `shine_login_guard` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` |
| `shine_users` | `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` |
| `shine_payments` | `m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR` |
Если эти адреса меняются, нужно синхронно обновить:
1. `shine-solana/shine/Anchor.toml`
2. `declare_id!` в `programs/*/src/lib.rs`
3. `programs/common/src/deploy_config.rs`
4. UI/серверные константы, перечисленные в `Dev_Docs/Инициализация_Solana_регистрации/README.md`
## Ключи и authority
Для удобного понимания на старте можно считать, что есть четыре группы ключей:
1. `key_1` / authority программы `shine_login_guard`.
- Сейчас программа только классифицирует логин.
- На первом этапе ее можно оставить под отдельным ключом.
- В будущем право обновления можно передать DAO.
2. `key_2` / authority программы `shine_users`.
- Отвечает за деплой/upgrade второй программы.
- Защищенное обновление economy-конфига в коде уже проверяет `DAO_AUTHORITY`.
- В целевой модели upgrade-authority второй программы нужно передать DAO.
3. `key_3` / authority программы `shine_payments`.
- Отвечает за деплой/upgrade третьей программы.
- Защищенные методы `update_coef_limit` и `grant_manager_limits` проверяют `dao_wallet` из `ConfigState`.
- В целевой модели upgrade-authority третьей программы нужно передать DAO.
4. DAO-ключи.
- Это управляющие кошельки/токены/realm governance.
- DAO может добавлять и отзывать управляющие ключи по голосованию.
- DAO-казна получает деньги от покупки тикетов и DAO-часть выплат из `inflow_vault`.
Адреса program id сейчас берутся из `programs/common/src/deploy_config.rs`. Для production/devnet можно подбирать vanity-адреса с понятным началом вроде `SHi...`, но это отдельная операция генерации ключей и деплоя.
## Счета и PDA
Постоянные PDA и счета:
1. `shine_users`
- `user_pda` — пользовательская запись по seed `login=<login>`, создается для каждого логина.
- `users_economy_config_pda` — общие параметры экономики регистрации и лимита.
2. `shine_payments`
- `config_pda` — хранит `dao_wallet` и адрес `inflow_vault`.
- `coef_limit_pda` — хранит коэффициент выплат, лимит очереди и награду вызывающему `step_payout`.
- `queues_pda` — агрегаты очередей выплат.
- `inflow_vault_pda` — PDA-вольт, куда `shine_users` переводит оплату регистрации и увеличения лимита.
- `ticket_pda` — отдельная PDA-запись тикета на каждую покупку/менеджерскую выдачу.
- `manager_allowance_pda` — PDA лимитов конкретного менеджера.
3. DAO
- `dao_wallet` / treasury — казна DAO.
- governance-аккаунты DAO — realm, governance, proposal/vote records и связанные аккаунты SPL Governance, если используется эта модель.
## Правило разделения с основным сервером
Solana-модуль лежит в основном репозитории как отдельная папка `shine-solana/shine/`, но не подключается автоматически к сборке или деплою основного сервера SHiNE. Команды `deployServer` и `deployUI` не должны деплоить Anchor-программы. Solana build/deploy выполняется отдельно из папки `shine-solana/shine/` по локальным правилам модуля.
## Движение денег
Основные потоки:
1. Регистрация пользователя через `shine_users::create_user_pda`.
- Платит `signer`.
- Деньги идут в `shine_payments::inflow_vault_pda`.
- Сумма состоит из регистрационной комиссии и оплаты дополнительного лимита.
2. Увеличение лимита через `shine_users::update_user_pda`.
- Платит `signer`.
- Деньги идут в тот же `inflow_vault_pda`.
- Сумма равна оплате дополнительного лимита.
3. Покупка тикета через `shine_payments::buy_ticket*`.
- Платит покупатель.
- Деньги сразу идут в `dao_wallet`.
- Одновременно создается тикет на выплату.
4. Выплата через `shine_payments::step_payout`.
- Вызвать может любой подписант.
- Деньги берутся из `inflow_vault_pda`.
- Часть идет получателю тикета.
- Часть идет в `dao_wallet`.
- Небольшая награда идет вызвавшему шаг выплат.
- Если очереди пустые, весь доступный остаток `inflow_vault_pda` переводится в DAO.
## Передача прав DAO
Минимальная целевая модель:
1. `shine_login_guard`
- Пока оставить на отдельном ключе `key_1`.
- Передачу DAO сделать позже, когда логика premium/trademark стабилизируется.
2. `shine_users`
- Economy-настройки уже должны обновляться DAO-authority.
- Upgrade-authority программы после проверки можно передать DAO.
3. `shine_payments`
- DAO уже управляет настройками выплат и лимитами менеджеров через `dao_wallet`.
- Upgrade-authority программы после проверки можно передать DAO.
4. DAO
- Управляет казной.
- Принимает решения голосованием.
- Добавляет/отзывает управляющие ключи.
- Вызывает защищенные методы второй и третьей программ.
- В будущем может принять управление первой программой.
## Детальные файлы
- [details/shine_login_guard.md](details/shine_login_guard.md)
- [details/shine_users.md](details/shine_users.md)
- [details/shine_payments.md](details/shine_payments.md)
- [details/shine_dao.md](details/shine_dao.md)
- [details/accounts_and_money_flow.md](details/accounts_and_money_flow.md)

View File

@ -0,0 +1,110 @@
# Счета, ключи и движение денег
## Кратко
В архитектуре есть три типа объектов:
1. Ключи программ и DAO.
2. PDA-счета состояния.
3. Денежные счета, через которые проходят SOL/lamports.
## Ключи
Минимальный набор для понимания:
1. `key_1` — deploy/upgrade authority `shine_login_guard`.
2. `key_2` — deploy/upgrade authority `shine_users`.
3. `key_3` — deploy/upgrade authority `shine_payments`.
4. `DAO_AUTHORITY` — адрес, который имеет право менять защищенные настройки.
5. `DAO_TREASURY_WALLET` / `dao_wallet` — казна DAO.
6. `manager_wallet` — кошелек менеджера, которому DAO выдает лимиты на создание тикетов.
7. `user root_key` — корневой ключ пользователя для подписи пользовательской записи.
8. `user device_key` — ключ устройства пользователя.
9. `server_key` — ключ сервера пользователя, если пользователь является сервером.
Текущие адреса из `programs/common/src/deploy_config.rs`:
| Роль | Адрес |
| --- | --- |
| `SHINE_LOGIN_GUARD_PROGRAM_ID` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` |
| `SHINE_USERS_PROGRAM_ID` | `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` |
| `SHINE_PAYMENTS_PROGRAM_ID` | `m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR` |
| `DAO_AUTHORITY` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` |
| `DAO_TREASURY_WALLET` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` |
## Постоянные PDA
`shine_users`:
- `user_pda` — создается для каждого логина, seed `login=` + normalized login.
- `users_economy_config_pda` — один PDA с экономикой регистрации, seed `shine_users_economy_config`.
`shine_payments`:
- `config_pda` — один PDA конфига, seed `shine_payments_config`.
- `coef_limit_pda` — один PDA коэффициента/лимита/награды, seed `shine_payments_coef_limit`.
- `queues_pda` — один PDA агрегатов очередей, seed `shine_payments_queues`.
- `inflow_vault_pda` — один PDA-вольт входящих средств, seed `shine_payments_inflow_vault`.
- `ticket_pda` — много PDA, по одному на тикет, seed `shine_payments_q1_ticket` или `shine_payments_q2_ticket` + индекс.
- `manager_allowance_pda` — много PDA, по одному на менеджера, seed `shine_p_manager_allow` + адрес менеджера.
## Денежные потоки
### Регистрация
```text
user signer -> shine_users::create_user_pda -> shine_payments::inflow_vault_pda
```
Состав платежа:
- регистрационная комиссия;
- оплата `additional_limit`.
### Увеличение лимита
```text
user signer -> shine_users::update_user_pda -> shine_payments::inflow_vault_pda
```
Состав платежа:
- только оплата `additional_limit`.
### Покупка тикета
```text
buyer signer -> shine_payments::buy_ticket* -> dao_wallet
```
При этом создается `ticket_pda`, но деньги в `inflow_vault_pda` на этом шаге не идут.
### Выплата
```text
shine_payments::inflow_vault_pda -> ticket_recipient_wallet
shine_payments::inflow_vault_pda -> dao_wallet
shine_payments::inflow_vault_pda -> step_payout caller
```
Если очереди пустые:
```text
shine_payments::inflow_vault_pda -> dao_wallet
```
## Что нужно создать на старте
Минимально:
1. Три program id для `shine_login_guard`, `shine_users`, `shine_payments`.
2. Три upgrade-authority ключа или один временный deploy-ключ с четким планом передачи прав.
3. DAO authority/treasury.
4. `users_economy_config_pda`.
5. `shine_payments` PDA: `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda`.
Динамически будут создаваться:
- `user_pda` на каждого пользователя;
- `ticket_pda` на каждый тикет;
- `manager_allowance_pda` на каждого менеджера.

View File

@ -0,0 +1,74 @@
# SHiNE DAO
## Кратко
DAO — управляющий слой Solana-части SHiNE. В текущем коде это не отдельная Anchor-программа в `programs/`, а модель управления через DAO-кошелек, DAO-authority, governance-скрипты и будущую передачу upgrade-authority программ.
## Что DAO должно уметь
1. Управлять казной.
- Принимать средства на `dao_wallet`.
- Выплачивать средства со счета DAO по решениям голосования.
2. Управлять настройками `shine_users`.
- Обновлять регистрационную комиссию.
- Обновлять цену шага лимита.
- Обновлять стартовый бонус лимита.
3. Управлять настройками `shine_payments`.
- Обновлять коэффициент выплат.
- Обновлять лимит очереди.
- Обновлять награду за вызов `step_payout`.
4. Управлять менеджерами.
- Выдавать менеджеру лимит на добавление тикетов.
- Отдельно учитывать лимиты Q1 и Q2.
5. Управлять правами программ.
- Принять upgrade-authority `shine_users`.
- Принять upgrade-authority `shine_payments`.
- Позже принять upgrade-authority `shine_login_guard`, если это потребуется.
6. Управлять ключами DAO.
- Добавлять управляющие ключи.
- Отзывать или сжигать управляющие ключи.
- Делать это через голосование, а не вручную одним админом.
7. Фиксировать решения.
- Делать заявления/решения через governance-механику.
- Привязывать важные изменения к proposal/vote/execute.
## Текущие адреса управления
В общем deploy-конфиге сейчас есть два важных адреса:
- `DAO_AUTHORITY` — используется `shine_users` для проверки права менять economy-конфиг.
- `DAO_TREASURY_WALLET` — используется `shine_payments` как `dao_wallet`.
Сейчас они могут совпадать. В целевой DAO-модели их лучше рассматривать как разные роли:
- authority/governance signer — кто имеет право исполнять управленческие инструкции;
- treasury wallet — счет, куда приходят деньги DAO.
## Передача прав
Рекомендуемый порядок:
1. Сначала стабилизировать и проверить `shine_users` и `shine_payments`.
2. Передать DAO право обновлять настройки, если оно еще не передано.
3. Передать DAO upgrade-authority второй и третьей программ.
4. Оставить `shine_login_guard` на отдельном ключе до стабилизации словарей и правил логинов.
5. После стабилизации решить отдельным голосованием, передавать ли первую программу DAO.
## Важное разделение
Есть два разных типа прав:
1. Право вызвать защищенную функцию программы.
- Например, `update_coef_limit` или `grant_manager_limits`.
- Проверяется внутри программы по `dao_wallet` или `DAO_AUTHORITY`.
2. Право обновить саму программу.
- Это upgrade-authority Solana ProgramData.
- Оно передается отдельной Solana-командой/DAO-транзакцией и не равно обычному PDA-счету.

View File

@ -0,0 +1,58 @@
# `shine_login_guard`
## Кратко
`shine_login_guard` — первая программа Solana-модуля SHiNE. Она проверяет логин перед регистрацией пользователя и возвращает класс логина.
Папка программы: `shine-solana/shine/programs/shine_login_guard/`.
## Текущая функция
1. `classify_login(login: String)`
- Нормализует логин.
- Проверяет длину и допустимые символы.
- Сравнивает части логина со словарями premium/trademark.
- Возвращает результат через `set_return_data`.
Классы результата:
- `0` — обычный логин, регистрацию можно продолжать.
- `1` — premium-логин.
- `2` — trademark-логин, нужна отдельная проверка/разрешение.
## Правила нормализации и классификации
Текущая логика из `programs/shine_login_guard/src/lib.rs`:
- пустой логин или логин длиннее 20 символов получает класс `premium`;
- `_` при нормализации удаляется;
- допустимы только ASCII-буквы и цифры, остальные символы дают класс `premium`;
- после удаления `_` результат приводится к нижнему регистру;
- логины длиной 7 символов или меньше считаются `premium`;
- логин разбивается максимум на 3 словарных фрагмента;
- если среди найденных фрагментов есть trademark-слово, результат `trademark`;
- если найдены только premium-слова, результат `premium`;
- если разбиение по словарям не найдено, результат `free`.
Словари собираются на этапе build из файлов:
- `programs/shine_login_guard/src/dictionaries/premium/*.txt`
- `programs/shine_login_guard/src/dictionaries/trademarks/*.txt`
## Роль в общей схеме
`shine_users::create_user_pda` вызывает `shine_login_guard` через CPI и продолжает регистрацию только если логин получил класс `0`.
## Ключи и управление
На старте удобно считать, что у программы есть отдельный управляющий ключ `key_1`.
Текущая рекомендация:
- пока оставить `shine_login_guard` под отдельным ключом;
- не передавать ее DAO до стабилизации правил premium/trademark;
- позже можно передать upgrade-authority DAO, чтобы изменения словарей и правил проходили через голосование.
## Счета
Собственных постоянных PDA-счетов у программы сейчас нет. Для проверки нужен только подписант транзакции в `ClassifyLogin`.

View File

@ -0,0 +1,173 @@
# `shine_payments`
## Кратко
`shine_payments` — третья программа Solana-модуля SHiNE. Она отвечает за vault входящих средств, DAO-казну, покупку тикетов, менеджерские лимиты, очереди выплат и пошаговое исполнение выплат.
Папка программы: `shine-solana/shine/programs/shine_payments/`.
## Текущие функции
1. `init`
- Создает основные PDA: `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda`.
- Записывает `dao_wallet` и стартовые параметры выплат.
2. `update_coef_limit`
- Обновляет коэффициент выплаты, лимит очереди и награду вызвавшему `step_payout`.
- Требует подпись DAO-кошелька из `ConfigState`.
3. `grant_manager_limits`
- DAO выдает менеджеру лимиты на создание тикетов в очередях Q1/Q2.
- Создает или обновляет `manager_allowance_pda`.
4. `buy_ticket`
- Покупка тикета с суммой в lamports, пересчетом через Pyth SOL/USD.
5. `buy_ticket_usd`
- Покупка тикета от USD-центов с защитой по максимальному платежу в lamports.
6. `buy_ticket_sol`
- Покупка тикета в lamports с проверкой минимального ожидаемого USD-эквивалента.
7. `manager_add_ticket`
- Менеджер создает тикет за счет выданного ему DAO-лимита.
8. `step_payout`
- Любой подписант может вызвать шаг выплат.
- Программа выплачивает следующий тикет, DAO-часть и награду вызывающему.
9. `change_ticket_recipient`
- Текущий получатель тикета может поменять адрес получателя, если тикет еще не следующий на выплату.
## Аргументы инструкций
`init` аргументов не принимает.
`update_coef_limit`:
- `coef_ppm: u64`
- `limit_usd_cents: u64`
- `call_reward_lamports: u64`
`grant_manager_limits`:
- `manager_wallet: Pubkey`
- `add_q1_usd_cents: u64`
- `add_q2_usd_cents: u64`
`buy_ticket`:
- `amount_lamports: u64`
- `recipient_wallet: Pubkey`
`buy_ticket_usd`:
- `amount_usd_cents: u64`
- `max_pay_lamports: u64`
- `recipient_wallet: Pubkey`
`buy_ticket_sol`:
- `amount_lamports: u64`
- `min_expected_usd_cents: u64`
- `recipient_wallet: Pubkey`
`manager_add_ticket`:
- `queue_id: u8` — только `1` или `2`
- `recipient_wallet: Pubkey`
- `payout_usd_cents: u64`
`change_ticket_recipient`:
- `new_recipient_wallet: Pubkey`
## Главные PDA
1. `config_pda`
- Seed: `shine_payments_config`.
- Хранит `dao_wallet` и `inflow_vault`.
- Размер PDA: `8 + 160` байт.
2. `coef_limit_pda`
- Seed: `shine_payments_coef_limit`.
- Хранит коэффициент выплат, лимит и награду `step_payout`.
- Размер PDA: `8 + 96` байт.
3. `queues_pda`
- Seed: `shine_payments_queues`.
- Хранит агрегаты очередей Q1/Q2.
- Размер PDA: `8 + 192` байт.
4. `inflow_vault_pda`
- Seed: `shine_payments_inflow_vault`.
- Принимает деньги от `shine_users`.
- Из него выполняются выплаты тикетам, DAO и вызывающему `step_payout`.
- Размер PDA: `8 + 32` байт.
5. `ticket_pda`
- Seed зависит от очереди и индекса тикета.
- Отдельная PDA-запись на каждый тикет.
- Q1 seed: `shine_payments_q1_ticket` + `ticket_index`.
- Q2 seed: `shine_payments_q2_ticket` + `ticket_index`.
- Размер PDA: `8 + 160` байт.
6. `manager_allowance_pda`
- Seed: `shine_p_manager_allow` + адрес менеджера.
- Хранит доступный лимит менеджера по Q1/Q2.
- Размер PDA: `8 + 128` байт.
## Текущие параметры
Параметры initial config из `programs/shine_payments/src/settings.rs`:
| Поле | Значение | Смысл |
| --- | --- | --- |
| `START_COEF_PPM` | `5_000_000` | коэффициент 5.0x в ppm-масштабе |
| `START_LIMIT_USD_CENTS` | `1_000_000` | стартовый лимит Q1: 10_000 USD |
| `START_CALL_REWARD_LAMPORTS` | `8_000_000` | награда вызвавшему `step_payout`, 0.008 SOL |
| `MAX_CALL_REWARD_LAMPORTS` | `10_000_000` | максимум награды, 0.01 SOL |
| `ORACLE_MAX_AGE_SECS` | `120` | максимальный возраст цены Pyth |
Для расчетов используется Pyth SOL/USD:
- feed id: `0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d`
- price update account: `7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE`
## Деньги
Входы:
- из `shine_users` в `inflow_vault_pda` при регистрации и увеличении лимита;
- от покупателя тикета сразу в `dao_wallet` при `buy_ticket*`.
Выходы:
- из `inflow_vault_pda` получателю тикета;
- из `inflow_vault_pda` в `dao_wallet`;
- из `inflow_vault_pda` вызвавшему `step_payout`;
- если очереди пустые, весь доступный остаток `inflow_vault_pda` переводится в DAO.
## Очереди и выплаты
Выплаты идут строго пошагово:
- если есть невыплаченные Q1-тикеты, `step_payout` берет следующий Q1;
- если Q1 пустая, берется следующий Q2;
- для Q1 DAO-часть равна сумме тикета в USD;
- для Q2 DAO-часть равна двойной сумме тикета в USD;
- перед выплатой суммы пересчитываются из USD-центов в lamports по Pyth SOL/USD;
- если в `inflow_vault_pda` не хватает средств на тикет, DAO-часть и награду вызвавшему, шаг отклоняется.
`change_ticket_recipient` запрещает менять получателя у тикета, который является следующим на выплату.
## Ключи и управление
На старте удобно считать, что у программы есть отдельный управляющий ключ `key_3`.
Целевая модель:
- `update_coef_limit` вызывает DAO;
- `grant_manager_limits` вызывает DAO;
- upgrade-authority программы после проверки передается DAO;
- `step_payout` остается открытым для любого подписанта, чтобы выплаты не зависели от одного оператора.

View File

@ -0,0 +1,136 @@
# `shine_users`
## Кратко
`shine_users` — вторая программа Solana-модуля SHiNE. Она отвечает за создание и обновление пользовательской PDA-записи, проверку подписи записи, проверку логина через `shine_login_guard` и оплату регистрации/дополнительного лимита.
Папка программы: `shine-solana/shine/programs/shine_users/`.
## Текущие функции
1. `init_users_economy_config`
- Создает PDA с экономическими настройками пользователей.
- Записывает стартовую регистрационную комиссию, цену шага лимита и стартовый бонус лимита.
2. `update_users_economy_config`
- Обновляет экономические настройки.
- Требует подпись `DAO_AUTHORITY` из общего deploy-конфига.
3. `create_user_pda`
- Проверяет логин через `shine_login_guard`.
- Проверяет структуру полей пользователя.
- Проверяет подпись записи root-ключом пользователя.
- Создает `user_pda` по seed `login=<normalized_login>`.
- Переводит оплату регистрации и дополнительного лимита в `shine_payments::inflow_vault_pda`.
4. `update_user_pda`
- Проверяет неизменяемые поля пользователя.
- Проверяет `prev_hash`, новую подпись и новое состояние последнего блока.
- При необходимости расширяет PDA.
- Переводит оплату дополнительного лимита в `shine_payments::inflow_vault_pda`.
## Аргументы инструкций
`init_users_economy_config` аргументов не принимает.
`update_users_economy_config`:
- `registration_fee_lamports: u64`
- `lamports_per_limit_step: u64`
- `start_bonus_limit: u64`
`create_user_pda`:
- `login: String`
- `root_key: Pubkey`
- `created_at_ms: u64`
- `additional_limit: u64`
- `fields: UserMutableFields`
- `signature: Vec<u8>`
`update_user_pda`:
- `login: String`
- `root_key: Pubkey`
- `created_at_ms: u64`
- `updated_at_ms: u64`
- `version: u32`
- `prev_hash: Vec<u8>`
- `additional_limit: u64`
- `fields: UserMutableFields`
- `signature: Vec<u8>`
`UserMutableFields`:
- `device_key: Pubkey`
- `blockchain_public_key: Pubkey`
- `blockchain_name: String`
- `used_bytes: u64`
- `last_block_number: u32`
- `last_block_hash: Vec<u8>` — ровно 32 байта
- `last_block_signature: Vec<u8>` — ровно 64 байта
- `arweave_tx_id: String`
- `is_server: bool`
- `server_key: Pubkey`
- `server_address: String`
- `sync_servers: Vec<String>`
- `access_servers: Vec<String>`
- `trusted_count: u8`
## Главные PDA
1. `user_pda`
- PDA записи пользователя.
- Seed: `login=<normalized_login>`.
- Создается отдельно для каждого логина.
- Стартовый размер: `768` байт.
- При обновлении может расширяться через `realloc`, но один auto-realloc ограничен `10_000` байт.
2. `users_economy_config_pda`
- PDA с настройками экономики.
- Seed: `shine_users_economy_config`.
- Хранит регистрационную комиссию, цену шага лимита и стартовый бонус.
- Размер PDA: `8 + 96` байт.
## Текущие параметры экономики
Параметры initial config из `programs/shine_users/src/settings.rs`:
| Поле | Значение | Смысл |
| --- | --- | --- |
| `START_REGISTRATION_FEE_LAMPORTS` | `10_000_000` | стартовая комиссия регистрации, 0.01 SOL |
| `LIMIT_STEP` | `10_000` | шаг `additional_limit` |
| `START_LAMPORTS_PER_LIMIT_STEP` | `100_000` | 0.0001 SOL за один шаг лимита |
| `START_BONUS_LIMIT` | `100_000` | стартовый бесплатный лимит при регистрации |
`additional_limit` в create/update должен быть кратен `LIMIT_STEP`.
## Связь с другими программами
`shine_users` зависит от:
- `shine_login_guard` — для проверки логина при создании пользователя;
- `shine_payments` — для вычисления и проверки `inflow_vault_pda`, куда уходят платежи.
`create_user_pda` делает CPI-вызов `shine_login_guard::classify_login` и принимает только результат `0`. Premium/trademark логины сейчас отклоняются ошибками `PremiumLogin` или `TrademarkLoginRequiresReview`.
Подпись `user_pda` и подпись состояния последнего блока проверяются через встроенную Solana Ed25519-инструкцию, которая должна идти раньше инструкции `shine_users` в той же транзакции.
## Деньги
Деньги из `shine_users` идут только в `inflow_vault_pda` программы `shine_payments`.
Потоки:
- `create_user_pda`: регистрационная комиссия + оплата `additional_limit`;
- `update_user_pda`: оплата `additional_limit`, если она больше нуля.
## Ключи и управление
На старте удобно считать, что у программы есть отдельный управляющий ключ `key_2`.
Целевая модель:
- economy-настройки меняет DAO-authority;
- upgrade-authority программы после проверки передается DAO;
- пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате.

View File

@ -0,0 +1,54 @@
flowchart LR
U[Пользователь / signer]
B[Покупатель тикета]
M[Менеджер]
C[Любой caller step_payout]
LG[1. shine_login_guard<br/>classify_login]
USERS[2. shine_users<br/>create_user_pda / update_user_pda]
PAY[3. shine_payments<br/>vault / tickets / payouts]
DAO[SHiNE DAO<br/>governance / authority / treasury]
USERPDA[(user_pda<br/>по login)]
ECON[(users_economy_config_pda)]
CONFIG[(config_pda)]
COEF[(coef_limit_pda)]
QUEUES[(queues_pda)]
VAULT[(inflow_vault_pda)]
TICKET[(ticket_pda)]
ALLOW[(manager_allowance_pda)]
U -->|логин| USERS
USERS -->|CPI проверка| LG
USERS -->|создает/обновляет| USERPDA
USERS -->|читает экономику| ECON
U -->|регистрация / лимит| VAULT
DAO -->|update economy| USERS
DAO -->|update coef/limit| PAY
DAO -->|grant manager limits| PAY
DAO -->|создает/отзывает ключи| DAO
PAY --> CONFIG
PAY --> COEF
PAY --> QUEUES
PAY --> VAULT
PAY --> TICKET
PAY --> ALLOW
B -->|buy_ticket*| PAY
B -->|оплата покупки тикета| DAO
PAY -->|создает тикет| TICKET
M -->|manager_add_ticket| PAY
ALLOW -->|лимиты Q1/Q2| M
C -->|step_payout| PAY
VAULT -->|выплата тикета| U
VAULT -->|DAO-часть| DAO
VAULT -->|call reward| C
DAO -. upgrade authority после передачи .-> USERS
DAO -. upgrade authority после передачи .-> PAY
DAO -. позже возможно .-> LG

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

View File

@ -0,0 +1,139 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="900" viewBox="0 0 1400 900" role="img" aria-labelledby="title desc">
<title id="title">Архитектура Solana-программ SHiNE</title>
<desc id="desc">Схема трех программ, DAO, PDA-счетов и движения денег.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#2f3a45"/>
</marker>
<marker id="moneyArrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#0a7f62"/>
</marker>
<style>
.bg { fill: #f7f8fa; }
.title { font: 700 30px Arial, sans-serif; fill: #1f2933; }
.subtitle { font: 400 16px Arial, sans-serif; fill: #52606d; }
.box { fill: #ffffff; stroke: #9aa5b1; stroke-width: 2; rx: 8; }
.program { fill: #e8f1ff; stroke: #3465a4; }
.dao { fill: #fff3d6; stroke: #b7791f; }
.pda { fill: #edf7ed; stroke: #2f855a; }
.actor { fill: #f3e8ff; stroke: #805ad5; }
.txt { font: 700 17px Arial, sans-serif; fill: #1f2933; }
.small { font: 400 13px Arial, sans-serif; fill: #3e4c59; }
.line { stroke: #2f3a45; stroke-width: 2.2; fill: none; marker-end: url(#arrow); }
.money { stroke: #0a7f62; stroke-width: 3; fill: none; marker-end: url(#moneyArrow); }
.dashed { stroke-dasharray: 8 7; }
.legend { font: 400 14px Arial, sans-serif; fill: #3e4c59; }
</style>
</defs>
<rect class="bg" x="0" y="0" width="1400" height="900"/>
<text class="title" x="52" y="54">SHiNE Solana: программы, DAO, счета и движение денег</text>
<text class="subtitle" x="52" y="82">Текущая модель: три Anchor-программы, DAO/authority как управляющий слой, inflow vault и DAO treasury.</text>
<rect class="box actor" x="52" y="150" width="210" height="78"/>
<text class="txt" x="72" y="181">Пользователь</text>
<text class="small" x="72" y="206">signer, root_key, device_key</text>
<rect class="box actor" x="52" y="310" width="210" height="78"/>
<text class="txt" x="72" y="341">Покупатель тикета</text>
<text class="small" x="72" y="366">buy_ticket*</text>
<rect class="box actor" x="52" y="470" width="210" height="78"/>
<text class="txt" x="72" y="501">Менеджер</text>
<text class="small" x="72" y="526">manager_add_ticket</text>
<rect class="box actor" x="52" y="630" width="210" height="78"/>
<text class="txt" x="72" y="661">Любой caller</text>
<text class="small" x="72" y="686">step_payout</text>
<rect class="box program" x="360" y="126" width="270" height="96"/>
<text class="txt" x="382" y="160">1. shine_login_guard</text>
<text class="small" x="382" y="186">classify_login</text>
<text class="small" x="382" y="205">free / premium / trademark</text>
<rect class="box program" x="360" y="286" width="270" height="112"/>
<text class="txt" x="382" y="320">2. shine_users</text>
<text class="small" x="382" y="346">create_user_pda</text>
<text class="small" x="382" y="365">update_user_pda</text>
<text class="small" x="382" y="384">economy config</text>
<rect class="box program" x="360" y="518" width="270" height="122"/>
<text class="txt" x="382" y="552">3. shine_payments</text>
<text class="small" x="382" y="578">vault, tickets, queues</text>
<text class="small" x="382" y="597">grant_manager_limits</text>
<text class="small" x="382" y="616">step_payout</text>
<rect class="box dao" x="776" y="126" width="270" height="122"/>
<text class="txt" x="798" y="160">SHiNE DAO</text>
<text class="small" x="798" y="186">governance / authority</text>
<text class="small" x="798" y="205">treasury dao_wallet</text>
<text class="small" x="798" y="224">ключи через голосование</text>
<rect class="box pda" x="776" y="306" width="270" height="84"/>
<text class="txt" x="798" y="340">shine_users PDA</text>
<text class="small" x="798" y="365">user_pda, economy_config</text>
<rect class="box pda" x="776" y="500" width="270" height="150"/>
<text class="txt" x="798" y="534">shine_payments PDA</text>
<text class="small" x="798" y="560">config_pda, coef_limit_pda</text>
<text class="small" x="798" y="579">queues_pda</text>
<text class="small" x="798" y="598">inflow_vault_pda</text>
<text class="small" x="798" y="617">ticket_pda, manager_allowance</text>
<rect class="box pda" x="1134" y="500" width="214" height="88"/>
<text class="txt" x="1156" y="534">inflow_vault</text>
<text class="small" x="1156" y="560">деньги регистрации</text>
<rect class="box dao" x="1134" y="170" width="214" height="88"/>
<text class="txt" x="1156" y="204">DAO treasury</text>
<text class="small" x="1156" y="230">dao_wallet</text>
<path class="line" d="M262 189 C300 189, 318 334, 360 334"/>
<text class="small" x="270" y="286">регистрация / update</text>
<path class="line" d="M360 314 C322 250, 320 176, 360 174"/>
<text class="small" x="330" y="250">CPI login</text>
<path class="line" d="M630 342 L776 342"/>
<text class="small" x="646" y="329">создает/обновляет</text>
<path class="money" d="M262 205 C438 432, 1010 390, 1134 530"/>
<text class="small" x="430" y="430">регистрация и лимит -> inflow_vault</text>
<path class="money" d="M262 349 C540 260, 870 244, 1134 214"/>
<text class="small" x="538" y="270">покупка тикета -> DAO treasury</text>
<path class="line" d="M262 509 L360 579"/>
<text class="small" x="276" y="540">создать тикет</text>
<path class="line" d="M630 579 L776 575"/>
<text class="small" x="648" y="562">PDA состояния</text>
<path class="line" d="M1046 575 L1134 548"/>
<path class="money" d="M1134 560 C970 700, 580 728, 262 669"/>
<text class="small" x="650" y="720">call reward caller</text>
<path class="money" d="M1134 536 C860 754, 426 238, 262 194"/>
<text class="small" x="632" y="760">выплата получателю тикета</text>
<path class="money" d="M1241 500 L1241 258"/>
<text class="small" x="1254" y="380">DAO-часть выплат</text>
<path class="line" d="M776 188 L630 342"/>
<text class="small" x="642" y="250">update economy</text>
<path class="line" d="M776 216 C690 290, 666 516, 630 558"/>
<text class="small" x="654" y="438">settings / managers</text>
<path class="line dashed" d="M910 248 C850 702, 620 720, 520 640"/>
<text class="small" x="690" y="690">upgrade-authority: users/payments; login_guard позже</text>
<rect class="box" x="52" y="808" width="1296" height="54"/>
<line x1="74" y1="835" x2="132" y2="835" class="line"/>
<text class="legend" x="146" y="840">логические вызовы и управление</text>
<line x1="374" y1="835" x2="432" y2="835" class="money"/>
<text class="legend" x="446" y="840">движение SOL/lamports</text>
<line x1="682" y1="835" x2="740" y2="835" class="line dashed"/>
<text class="legend" x="754" y="840">будущая передача upgrade-authority DAO</text>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -18,11 +18,13 @@
- Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`. - Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`.
- `Caddyfile` на сервере должен смотреть в ту же директорию через `root * /home/player/SHiNE/shine-ui`. - `Caddyfile` на сервере должен смотреть в ту же директорию через `root * /home/player/SHiNE/shine-ui`.
- В `deploy_shine-PWA.sh` добавлена проверка: если `root` в `Caddyfile` не совпадает, деплой прерывается с ошибкой. - В `deploy_shine-PWA.sh` добавлена проверка: скрипт ищет блок `shineup.me { ... }` (или значение `EXPECTED_CADDY_SITE`) и проверяет `root` внутри этого блока.
- Если `root` внутри целевого блока не совпадает, деплой прерывается с ошибкой.
- Для ручного обхода проверки (только осознанно): `ALLOW_CADDY_MISMATCH=1 ./gradlew deployUI`. - Для ручного обхода проверки (только осознанно): `ALLOW_CADDY_MISMATCH=1 ./gradlew deployUI`.
- При необходимости можно явно переопределить путь деплоя: - При необходимости можно явно переопределить путь деплоя:
- `REMOTE_UI_DIR=/нужный/путь ./gradlew deployUI` - `REMOTE_UI_DIR=/нужный/путь ./gradlew deployUI`
- `EXPECTED_CADDY_UI_ROOT=/нужный/путь ./gradlew deployUI` - `EXPECTED_CADDY_UI_ROOT=/нужный/путь ./gradlew deployUI`
- `EXPECTED_CADDY_SITE=example.com ./gradlew deployUI`
### Важно для локального UI (history-router / Ctrl+F5) ### Важно для локального UI (history-router / Ctrl+F5)

View File

@ -0,0 +1,91 @@
# Деплой и инициализация Solana-регистрации (две обязательные программы)
## Коротко
Для рабочей регистрации пользователя нужны **обе** программы:
1. `shine_users` — хранение и обновление `user_pda`, economy-конфиг, логика регистрации.
2. `shine_login_guard` — проверка/классификация логина (CPI из `shine_users`).
Если задеплоена только одна из них — регистрация неработоспособна.
## Актуальные адреса (devnet)
- `shine_users` (регистрация пользователей):
`FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- `shine_login_guard`:
`3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
- `shine_payments`:
`m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR`
## Подтверждение деплоя
- Сеть: `https://api.devnet.solana.com`
- `shine_users`:
- `Program ID`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- TX deploy: `5VzfpSirFCRqPUZfvAt3eADY9KnowW79PKZ1pCQAa2DJGiztj4dUYYXrSQNmWEhPVu6mPSDfcuHzFyEVmoKLa9DM`
- `shine_login_guard`:
- `Program ID`: `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
- TX deploy: `5iptngPYrLLjPE3Xby24zyNW3edVUnBNLBx785vjojMoq5JNLFNQvLNAm3jNYHbpf2B36qtbpTNzcvUNyRDqm1Mf`
## Порядок деплоя (devnet)
1. Убедиться, что CLI смотрит в devnet и у кошелька есть SOL.
2. Собрать и задеплоить `shine_login_guard`.
3. Собрать и задеплоить `shine_users`.
4. Проверить, что адреса совпадают между:
- `Anchor.toml`
- `declare_id!` в `programs/*/src/lib.rs`
- UI/серверными константами.
5. Выполнить `init_users_economy_config` (один раз на программу `shine_users`).
Пример команд:
```bash
cd shine-solana/shine
solana config get
solana balance
anchor build -p shine_login_guard
anchor deploy -p shine_login_guard
anchor build -p shine_users
anchor deploy -p shine_users
```
## Куда вписаны адреса в проекте
### UI
- Общие Solana-константы:
- `shine-UI/js/solana-programs.js`
- Страница инициализации:
- `shine-UI/js/pages/solana-users-init-view.js`
- Переход на страницу:
- `shine-UI/js/pages/developer-settings-view.js`
### Сервер
- Серверные константы Solana:
- `shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java`
## Как запустить инициализацию economy PDA
1. Открыть UI.
2. Перейти: `Профиль -> Настройки -> Настройки разработчика -> Solana: init регистрации`.
3. Подключить кошелёк (Phantom, devnet).
4. Нажать `Запустить init_users_economy_config`.
5. Дождаться статуса `Успешно`.
Страница сама вычисляет PDA `users_economy_config` по seed:
- seed: `shine_users_economy_config`
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
## Важно
- `init_users_economy_config` выполняется один раз на программу.
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
- Серверные приватные ключи для Solana не используются: подписание делается кошельком пользователя в UI.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
Несовпадение адреса приведёт к ошибке регистрации.

View File

@ -0,0 +1,119 @@
# ESP32-S3-Touch-AMOLED-2.16 Codex Guide
Этот файл переносится в другие проекты как готовая инструкция для Codex по этой плате.
## 1) Что это за плата
- Модель: `Waveshare ESP32-S3-Touch-AMOLED-2.16`
- MCU: `ESP32-S3` (flash 16MB, PSRAM 8MB)
- Экран: AMOLED, физически 480x480, углы скруглены (часть крайних пикселей может быть невидима)
- Touch: CST92xx
- IMU: QMI8658
- Аудио:
- DAC/вывод (динамик): ES8311
- ADC/вход (микрофоны): ES7210
## 2) Что уже установлено в этой среде
- Ubuntu
- `arduino-cli 1.4.0`
- `esp32:esp32` core `3.3.5`
- `esptool` из `~/.arduino15/packages/esp32/tools/esptool_py/5.1.0/esptool`
- USB порт платы: обычно `/dev/ttyACM0`
Проверка:
```bash
arduino-cli version
arduino-cli core list
arduino-cli board list
ls -l /dev/ttyACM0
```
## 3) Структура подпроекта (эталон)
- `official-demo/` — официальный repo Waveshare (примеры+библиотеки)
- `original-firmware/` — backup/restore заводской прошивки
- `test-device/` — прошивки и `burn.sh`
- `reference/` — заметки и ссылки
## 4) Бэкап перед любыми экспериментами
```bash
cd ESP32-S3-Touch-AMOLED-2.16/original-firmware
./backup_factory.sh
```
Ожидаемый результат:
- `factory-full-16mb.bin`
- `factory-full-16mb.bin.sha256`
Восстановление:
```bash
./restore_factory_backup.sh
```
## 5) Деплой (прошивка) — стандарт
Главный скрипт:
```bash
cd ESP32-S3-Touch-AMOLED-2.16/test-device
./burn.sh <mode>
```
Режимы:
- `hello` — базовый экран
- `widgets` — экран+touch+IMU (официальный пример)
- `audio` — тест аудио тракта
- `simple` — кастомный интеграционный тест (экран, touch, запись/воспроизведение, VU, tilt)
## 6) Как писать код под эту плату (важно)
1. **Экран**
- Рабочее разрешение использовать `480x480`.
- Не рисовать критичный текст/кнопки впритык к краю; держать safe margin (`~20px+`) из-за скругленных углов.
- Не делать полный `fillScreen` в каждом loop: только частичные обновления (`fillRect`/локальные перерисовки), иначе мерцание.
2. **Touch**
- Настройка CST:
- `setMaxCoordinates(480, 480)`
- `setSwapXY(true)`
- `setMirrorXY(true, false)`
- Обрабатывать touch по IRQ + `getPoint`.
- После смещения UI обязательно пересчитывать hitbox кнопок.
3. **Аудио**
- Для динамика инициализировать `ES8311`.
- Для микрофона обязательно инициализировать `ES7210`; без этого запись может быть пустой.
- Для отладки записи показывать VU/peak на экране во время `RECORD`.
- Для быстрой проверки тракта всегда держать кнопку `BEEP` (тон), чтобы отделить проблему динамика от проблемы микрофона.
4. **IMU**
- QMI8658 обновлять с ограниченной частотой (например 80150 мс для UI-строки), чтобы не шуметь перерисовками.
5. **Стабильность UI**
- Статика: рисуется один раз в setup.
- Динамика: отдельная зона, перерисовывать только по изменению данных.
## 7) Рекомендуемый workflow для Codex
1. Проверить порт и инструменты.
2. Если новая плата/первый запуск — сделать backup flash.
3. Собрать и залить `simple`.
4. Пройти ручной чек:
- экран отображает текст без обрезки,
- touch срабатывает по кнопкам,
- `BEEP` слышно,
- VU двигается во время записи,
- `PLAY` воспроизводит записанное,
- `Tilt` меняется при повороте.
5. Только после этого усложнять приложение.
## 8) Ссылки
- Product page: https://www.waveshare.com/product/arduino/boards-kits/esp32-s3/esp32-s3-touch-amoled-2.16.htm
- Docs: https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16
- Arduino setup: https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16/Development-Environment-Setup-Arduino
- Official examples: https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.16

View File

@ -0,0 +1,25 @@
# ESP32-S3-Touch-AMOLED-2.16
Подпроект для Waveshare `ESP32-S3-Touch-AMOLED-2.16`.
Структура:
- `official-demo/` — официальный репозиторий примеров Waveshare
- `original-firmware/` — резервная копия заводской прошивки
- `test-device/` — скрипты быстрой проверки устройства
- `reference/` — локальные заметки по документации и железу
Примечание по git:
- `official-demo/` держать как локальный внешний checkout из `https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.16`, в основной git его не добавлять.
- `original-firmware/*.bin` — локальный дамп конкретной платы, в git не добавлять.
- `.arduino-build/` и готовые `.bin/.elf/.map` — сборочные артефакты, в git не добавлять.
Быстрый старт:
1. Сделать backup текущей прошивки:
- `cd original-firmware && ./backup_factory.sh`
2. Залить тест экрана/тача:
- `cd ../test-device && ./burn.sh widgets`
3. Залить тест динамика:
- `cd ../test-device && ./burn.sh audio`

View File

@ -0,0 +1,11 @@
# Factory Firmware Backup
Здесь хранится полный дамп флеша текущего состояния платы.
Файлы:
- `factory-full-16mb.bin` — полный дамп флеша `0x000000..0xFFFFFF`
- `factory-full-16mb.bin.sha256` — контрольная сумма
Скрипты:
- `backup_factory.sh` — снять резервную копию
- `restore_factory_backup.sh` — восстановить резервную копию на плату

View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PORT="${PORT:-/dev/ttyACM0}"
ESPTOOL="${ESPTOOL:-$HOME/.arduino15/packages/esp32/tools/esptool_py/5.1.0/esptool}"
BAUD="${BAUD:-921600}"
OUT_BIN="${ROOT_DIR}/factory-full-16mb.bin"
echo "== Port: ${PORT}"
echo "== Output: ${OUT_BIN}"
echo "== Esptool: ${ESPTOOL}"
echo "== Baud: ${BAUD}"
"${ESPTOOL}" --port "${PORT}" --baud "${BAUD}" read-flash 0 0x1000000 "${OUT_BIN}"
sha256sum "${OUT_BIN}" | tee "${OUT_BIN}.sha256"
echo
echo "== Backup done."

View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PORT="${PORT:-/dev/ttyACM0}"
ESPTOOL="${ESPTOOL:-$HOME/.arduino15/packages/esp32/tools/esptool_py/5.1.0/esptool}"
IN_BIN="${IN_BIN:-${ROOT_DIR}/factory-full-16mb.bin}"
if [[ ! -f "${IN_BIN}" ]]; then
echo "Backup file not found: ${IN_BIN}" >&2
exit 1
fi
echo "== Port: ${PORT}"
echo "== Input: ${IN_BIN}"
echo "== Esptool: ${ESPTOOL}"
"${ESPTOOL}" --port "${PORT}" --baud 921600 write-flash 0 "${IN_BIN}"
echo
echo "== Restore done."

View File

@ -0,0 +1,17 @@
# Reference Notes
Модель: Waveshare `ESP32-S3-Touch-AMOLED-2.16` (SKU 33969/33970)
Полезные страницы:
- Product: https://www.waveshare.com/product/arduino/boards-kits/esp32-s3/esp32-s3-touch-amoled-2.16.htm
- Docs home: https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16
- Arduino setup: https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16/Development-Environment-Setup-Arduino
- Official examples: https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-2.16
Ключевое по железу (по документации):
- ESP32-S3R8, 16MB flash, 8MB PSRAM
- AMOLED 2.16" 480x480
- Capacitive touch (CST9220)
- IMU QMI8658
- Audio codec ES8311 (playback) и ES7210 (microphones)
- TF slot (SD), сейчас без карты

View File

@ -0,0 +1,18 @@
# Test Device
Скрипт заливает официальные Arduino-примеры для быстрой проверки платы.
Для режимов `widgets`, `audio` и `hello` рядом должен лежать локальный checkout `official-demo/` из официального репозитория Waveshare. В основной git он не добавляется, потому что это большой внешний набор примеров, библиотек, прошивок и артефактов.
Режимы:
- `widgets` — экран + touch + IMU (пример `05_LVGL_Widgets`)
- `audio` — динамик/аудио-кодек (пример `07_ES8311`)
- `hello` — базовый тест экрана (пример `01_HelloWorld`)
- `simple` — простой кастомный тест: экран + touch + запись/проигрывание + наклон (IMU)
Запуск:
- `./burn.sh widgets`
- `./burn.sh audio`
- `./burn.sh hello`
- `./burn.sh simple`

View File

@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BOARD_DIR="$(cd "${ROOT_DIR}/.." && pwd)"
DEMO_BASE="${BOARD_DIR}/official-demo/examples/Arduino-v3.3.5"
MODE="${1:-widgets}"
PORT="${PORT:-/dev/ttyACM0}"
FQBN="${FQBN:-esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,UploadSpeed=921600,CPUFreq=240,FlashMode=dio,FlashSize=16M,PartitionScheme=app3M_fat9M_16MB,PSRAM=opi}"
BUILD_DIR="${BUILD_DIR:-${ROOT_DIR}/.arduino-build/build-${MODE}}"
OUT_DIR="${OUT_DIR:-${ROOT_DIR}/.arduino-build/out-${MODE}}"
case "${MODE}" in
hello) SKETCH_DIR="${DEMO_BASE}/examples/01_HelloWorld" ;;
widgets) SKETCH_DIR="${DEMO_BASE}/examples/05_LVGL_Widgets" ;;
audio) SKETCH_DIR="${DEMO_BASE}/examples/07_ES8311" ;;
simple) SKETCH_DIR="${ROOT_DIR}/simple_av_test" ;;
*)
echo "Unknown mode: ${MODE}" >&2
echo "Use one of: hello, widgets, audio, simple" >&2
exit 2
;;
esac
echo "== Mode: ${MODE}"
echo "== Sketch: ${SKETCH_DIR}"
echo "== Port: ${PORT}"
echo "== FQBN: ${FQBN}"
mkdir -p "${BUILD_DIR}" "${OUT_DIR}"
arduino-cli compile \
--clean \
--fqbn "${FQBN}" \
--build-path "${BUILD_DIR}" \
--output-dir "${OUT_DIR}" \
--library "${DEMO_BASE}/libraries/GFX_Library_for_Arduino" \
--library "${DEMO_BASE}/libraries/SensorLib" \
--library "${DEMO_BASE}/libraries/XPowersLib" \
--library "${DEMO_BASE}/libraries/lvgl" \
--library "${DEMO_BASE}/libraries/Mylibrary" \
"${SKETCH_DIR}"
arduino-cli upload \
-p "${PORT}" \
--fqbn "${FQBN}" \
--input-dir "${OUT_DIR}" \
"${SKETCH_DIR}"
echo
echo "== Done."

View File

@ -0,0 +1,136 @@
/*
* ESPRESSIF MIT License
*
* Copyright (c) 2018 <ESPRESSIF SYSTEMS (SHANGHAI) PTE LTD>
*
* Permission is hereby granted for use on all ESPRESSIF SYSTEMS products, in which case,
* it is free of charge, to any person obtaining a copy of this software and associated
* documentation files (the "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished
* to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
*/
#ifndef _AUDIO_HAL_H_
#define _AUDIO_HAL_H_
#define AUDIO_HAL_VOL_DEFAULT 60
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Select media hal codec mode
*/
typedef enum {
AUDIO_HAL_CODEC_MODE_ENCODE = 1, /*!< select adc */
AUDIO_HAL_CODEC_MODE_DECODE, /*!< select dac */
AUDIO_HAL_CODEC_MODE_BOTH, /*!< select both adc and dac */
AUDIO_HAL_CODEC_MODE_LINE_IN, /*!< set adc channel */
} audio_hal_codec_mode_t;
/**
* @brief Select adc channel for input mic signal
*/
typedef enum {
AUDIO_HAL_ADC_INPUT_LINE1 = 0x00, /*!< mic input to adc channel 1 */
AUDIO_HAL_ADC_INPUT_LINE2, /*!< mic input to adc channel 2 */
AUDIO_HAL_ADC_INPUT_ALL, /*!< mic input to both channels of adc */
AUDIO_HAL_ADC_INPUT_DIFFERENCE, /*!< mic input to adc difference channel */
} audio_hal_adc_input_t;
/**
* @brief Select channel for dac output
*/
typedef enum {
AUDIO_HAL_DAC_OUTPUT_LINE1 = 0x00, /*!< dac output signal to channel 1 */
AUDIO_HAL_DAC_OUTPUT_LINE2, /*!< dac output signal to channel 2 */
AUDIO_HAL_DAC_OUTPUT_ALL, /*!< dac output signal to both channels */
} audio_hal_dac_output_t;
/**
* @brief Select operating mode i.e. start or stop for audio codec chip
*/
typedef enum {
AUDIO_HAL_CTRL_STOP = 0x00, /*!< set stop mode */
AUDIO_HAL_CTRL_START = 0x01, /*!< set start mode */
} audio_hal_ctrl_t;
/**
* @brief Select I2S interface operating mode i.e. master or slave for audio codec chip
*/
typedef enum {
AUDIO_HAL_MODE_SLAVE = 0x00, /*!< set slave mode */
AUDIO_HAL_MODE_MASTER = 0x01, /*!< set master mode */
} audio_hal_iface_mode_t;
/**
* @brief Select I2S interface samples per second
*/
typedef enum {
AUDIO_HAL_08K_SAMPLES, /*!< set to 8k samples per second */
AUDIO_HAL_11K_SAMPLES, /*!< set to 11.025k samples per second */
AUDIO_HAL_16K_SAMPLES, /*!< set to 16k samples in per second */
AUDIO_HAL_22K_SAMPLES, /*!< set to 22.050k samples per second */
AUDIO_HAL_24K_SAMPLES, /*!< set to 24k samples in per second */
AUDIO_HAL_32K_SAMPLES, /*!< set to 32k samples in per second */
AUDIO_HAL_44K_SAMPLES, /*!< set to 44.1k samples per second */
AUDIO_HAL_48K_SAMPLES, /*!< set to 48k samples per second */
} audio_hal_iface_samples_t;
/**
* @brief Select I2S interface number of bits per sample
*/
typedef enum {
AUDIO_HAL_BIT_LENGTH_16BITS = 1, /*!< set 16 bits per sample */
AUDIO_HAL_BIT_LENGTH_24BITS, /*!< set 24 bits per sample */
AUDIO_HAL_BIT_LENGTH_32BITS, /*!< set 32 bits per sample */
} audio_hal_iface_bits_t;
/**
* @brief Select I2S interface format for audio codec chip
*/
typedef enum {
AUDIO_HAL_I2S_NORMAL = 0, /*!< set normal I2S format */
AUDIO_HAL_I2S_LEFT, /*!< set all left format */
AUDIO_HAL_I2S_RIGHT, /*!< set all right format */
AUDIO_HAL_I2S_DSP, /*!< set dsp/pcm format */
} audio_hal_iface_format_t;
/**
* @brief I2s interface configuration for audio codec chip
*/
typedef struct {
audio_hal_iface_mode_t mode; /*!< audio codec chip mode */
audio_hal_iface_format_t fmt; /*!< I2S interface format */
audio_hal_iface_samples_t samples; /*!< I2S interface samples per second */
audio_hal_iface_bits_t bits; /*!< i2s interface number of bits per sample */
} audio_hal_codec_i2s_iface_t;
/**
* @brief Configure media hal for initialization of audio codec chip
*/
typedef struct {
audio_hal_adc_input_t adc_input; /*!< set adc channel */
audio_hal_dac_output_t dac_output; /*!< set dac channel */
audio_hal_codec_mode_t codec_mode; /*!< select codec mode: adc, dac or both */
audio_hal_codec_i2s_iface_t i2s_iface; /*!< set I2S interface configuration */
} audio_hal_codec_config_t;
#ifdef __cplusplus
}
#endif
#endif //__AUDIO_HAL_H__

View File

@ -0,0 +1,549 @@
/*
* ESPRESSIF MIT License
*
* Copyright (c) 2021 <ESPRESSIF SYSTEMS (SHANGHAI) CO., LTD>
*
* Permission is hereby granted for use on all ESPRESSIF SYSTEMS products, in which case,
* it is free of charge, to any person obtaining a copy of this software and associated
* documentation files (the "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished
* to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
*/
#ifdef ESP32
#include <Wire.h>
#include <string.h>
#include "esp_log.h"
#include "es7210.h"
#define I2S_DSP_MODE_A 0
#define MCLK_DIV_FRE 256
#define ES7210_MCLK_SOURCE FROM_CLOCK_DOUBLE_PIN /* In master mode, 0 : MCLK from pad 1 : MCLK from clock doubler */
#define FROM_PAD_PIN 0
#define FROM_CLOCK_DOUBLE_PIN 1
static TwoWire *es7210wire;
static es7210_gain_value_t gain;
/*
* Clock coefficient structer
*/
struct _coeff_div_es7210 {
uint32_t mclk; /* mclk frequency */
uint32_t lrck; /* lrck */
uint8_t ss_ds;
uint8_t adc_div; /* adcclk divider */
uint8_t dll; /* dll_bypass */
uint8_t doubler; /* doubler enable */
uint8_t osr; /* adc osr */
uint8_t mclk_src; /* select mclk source */
uint32_t lrck_h; /* The high 4 bits of lrck */
uint32_t lrck_l; /* The low 8 bits of lrck */
};
static const char *TAG = "ES7210";
static es7210_input_mics_t mic_select = (es7210_input_mics_t)(ES7210_INPUT_MIC1 | ES7210_INPUT_MIC2 | ES7210_INPUT_MIC3 | ES7210_INPUT_MIC4);
/* Codec hifi mclk clock divider coefficients
* MEMBER REG
* mclk: 0x03
* lrck: standard
* ss_ds: --
* adc_div: 0x02
* dll: 0x06
* doubler: 0x02
* osr: 0x07
* mclk_src: 0x03
* lrckh: 0x04
* lrckl: 0x05
*/
static const struct _coeff_div_es7210 coeff_div[] = {
//mclk lrck ss_ds adc_div dll doubler osr mclk_src lrckh lrckl
/* 8k */
{12288000, 8000 , 0x00, 0x03, 0x01, 0x00, 0x20, 0x00, 0x06, 0x00},
{16384000, 8000 , 0x00, 0x04, 0x01, 0x00, 0x20, 0x00, 0x08, 0x00},
{19200000, 8000 , 0x00, 0x1e, 0x00, 0x01, 0x28, 0x00, 0x09, 0x60},
{4096000, 8000 , 0x00, 0x01, 0x01, 0x00, 0x20, 0x00, 0x02, 0x00},
/* 11.025k */
{11289600, 11025, 0x00, 0x02, 0x01, 0x00, 0x20, 0x00, 0x01, 0x00},
/* 12k */
{12288000, 12000, 0x00, 0x02, 0x01, 0x00, 0x20, 0x00, 0x04, 0x00},
{19200000, 12000, 0x00, 0x14, 0x00, 0x01, 0x28, 0x00, 0x06, 0x40},
/* 16k */
{4096000, 16000, 0x00, 0x01, 0x01, 0x01, 0x20, 0x00, 0x01, 0x00},
{19200000, 16000, 0x00, 0x0a, 0x00, 0x00, 0x1e, 0x00, 0x04, 0x80},
{16384000, 16000, 0x00, 0x02, 0x01, 0x00, 0x20, 0x00, 0x04, 0x00},
{12288000, 16000, 0x00, 0x03, 0x01, 0x01, 0x20, 0x00, 0x03, 0x00},
/* 22.05k */
{11289600, 22050, 0x00, 0x01, 0x01, 0x00, 0x20, 0x00, 0x02, 0x00},
/* 24k */
{12288000, 24000, 0x00, 0x01, 0x01, 0x00, 0x20, 0x00, 0x02, 0x00},
{19200000, 24000, 0x00, 0x0a, 0x00, 0x01, 0x28, 0x00, 0x03, 0x20},
/* 32k */
{12288000, 32000, 0x00, 0x03, 0x00, 0x00, 0x20, 0x00, 0x01, 0x80},
{16384000, 32000, 0x00, 0x01, 0x01, 0x00, 0x20, 0x00, 0x02, 0x00},
{19200000, 32000, 0x00, 0x05, 0x00, 0x00, 0x1e, 0x00, 0x02, 0x58},
/* 44.1k */
{11289600, 44100, 0x00, 0x01, 0x01, 0x01, 0x20, 0x00, 0x01, 0x00},
/* 48k */
{12288000, 48000, 0x00, 0x01, 0x01, 0x01, 0x20, 0x00, 0x01, 0x00},
{19200000, 48000, 0x00, 0x05, 0x00, 0x01, 0x28, 0x00, 0x01, 0x90},
/* 64k */
{16384000, 64000, 0x01, 0x01, 0x01, 0x00, 0x20, 0x00, 0x01, 0x00},
{19200000, 64000, 0x00, 0x05, 0x00, 0x01, 0x1e, 0x00, 0x01, 0x2c},
/* 88.2k */
{11289600, 88200, 0x01, 0x01, 0x01, 0x01, 0x20, 0x00, 0x00, 0x80},
/* 96k */
{12288000, 96000, 0x01, 0x01, 0x01, 0x01, 0x20, 0x00, 0x00, 0x80},
{19200000, 96000, 0x01, 0x05, 0x00, 0x01, 0x28, 0x00, 0x00, 0xc8},
};
static esp_err_t es7210_write_reg(uint8_t reg_addr, uint8_t data)
{
es7210wire->beginTransmission(ES7210_ADDR);
es7210wire->write(reg_addr);
es7210wire->write(data);
return es7210wire->endTransmission();
}
static esp_err_t es7210_update_reg_bit(uint8_t reg_addr, uint8_t update_bits, uint8_t data)
{
uint8_t regv;
regv = es7210_read_reg(reg_addr);
regv = (regv & (~update_bits)) | (update_bits & data);
return es7210_write_reg(reg_addr, regv);
}
static int get_coeff(uint32_t mclk, uint32_t lrck)
{
for (int i = 0; i < (sizeof(coeff_div) / sizeof(coeff_div[0])); i++) {
if (coeff_div[i].lrck == lrck && coeff_div[i].mclk == mclk)
return i;
}
return -1;
}
int8_t get_es7210_mclk_src(void)
{
return ES7210_MCLK_SOURCE;
}
int es7210_read_reg(uint8_t reg_addr)
{
uint8_t data;
es7210wire->beginTransmission(ES7210_ADDR);
es7210wire->write(reg_addr);
es7210wire->endTransmission(false);
es7210wire->requestFrom(ES7210_ADDR, (size_t)1);
data = es7210wire->read();
return (int)data;
}
esp_err_t es7210_config_sample(audio_hal_iface_samples_t sample)
{
uint8_t regv;
int coeff;
int sample_fre = 0;
int mclk_fre = 0;
esp_err_t ret = ESP_OK;
switch (sample) {
case AUDIO_HAL_08K_SAMPLES:
sample_fre = 8000;
break;
case AUDIO_HAL_11K_SAMPLES:
sample_fre = 11025;
break;
case AUDIO_HAL_16K_SAMPLES:
sample_fre = 16000;
break;
case AUDIO_HAL_22K_SAMPLES:
sample_fre = 22050;
break;
case AUDIO_HAL_24K_SAMPLES:
sample_fre = 24000;
break;
case AUDIO_HAL_32K_SAMPLES:
sample_fre = 32000;
break;
case AUDIO_HAL_44K_SAMPLES:
sample_fre = 44100;
break;
case AUDIO_HAL_48K_SAMPLES:
sample_fre = 48000;
break;
default:
ESP_LOGE(TAG, "Unable to configure sample rate %dHz", sample_fre);
break;
}
mclk_fre = sample_fre * MCLK_DIV_FRE;
coeff = get_coeff(mclk_fre, sample_fre);
if (coeff < 0) {
ESP_LOGE(TAG, "Unable to configure sample rate %dHz with %dHz MCLK", sample_fre, mclk_fre);
return ESP_FAIL;
}
/* Set clock parammeters */
if (coeff >= 0) {
/* Set adc_div & doubler & dll */
regv = es7210_read_reg(ES7210_MAINCLK_REG02) & 0x00;
regv |= coeff_div[coeff].adc_div;
regv |= coeff_div[coeff].doubler << 6;
regv |= coeff_div[coeff].dll << 7;
ret |= es7210_write_reg(ES7210_MAINCLK_REG02, regv);
/* Set osr */
regv = coeff_div[coeff].osr;
ret |= es7210_write_reg(ES7210_OSR_REG07, regv);
/* Set lrck */
regv = coeff_div[coeff].lrck_h;
ret |= es7210_write_reg(ES7210_LRCK_DIVH_REG04, regv);
regv = coeff_div[coeff].lrck_l;
ret |= es7210_write_reg(ES7210_LRCK_DIVL_REG05, regv);
}
return ret;
}
esp_err_t es7210_mic_select(es7210_input_mics_t mic)
{
esp_err_t ret = ESP_OK;
mic_select = mic;
if (mic_select & (ES7210_INPUT_MIC1 | ES7210_INPUT_MIC2 | ES7210_INPUT_MIC3 | ES7210_INPUT_MIC4)) {
for (int i = 0; i < 4; i++) {
ret |= es7210_update_reg_bit(ES7210_MIC1_GAIN_REG43 + i, 0x10, 0x00);
}
ret |= es7210_write_reg(ES7210_MIC12_POWER_REG4B, 0xff);
ret |= es7210_write_reg(ES7210_MIC34_POWER_REG4C, 0xff);
if (mic_select & ES7210_INPUT_MIC1) {
ESP_LOGI(TAG, "Enable ES7210_INPUT_MIC1");
ret |= es7210_update_reg_bit(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00);
ret |= es7210_write_reg(ES7210_MIC12_POWER_REG4B, 0x00);
ret |= es7210_update_reg_bit(ES7210_MIC1_GAIN_REG43, 0x10, 0x10);
}
if (mic_select & ES7210_INPUT_MIC2) {
ESP_LOGI(TAG, "Enable ES7210_INPUT_MIC2");
ret |= es7210_update_reg_bit(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00);
ret |= es7210_write_reg(ES7210_MIC12_POWER_REG4B, 0x00);
ret |= es7210_update_reg_bit(ES7210_MIC2_GAIN_REG44, 0x10, 0x10);
}
if (mic_select & ES7210_INPUT_MIC3) {
ESP_LOGI(TAG, "Enable ES7210_INPUT_MIC3");
ret |= es7210_update_reg_bit(ES7210_CLOCK_OFF_REG01, 0x15, 0x00);
ret |= es7210_write_reg(ES7210_MIC34_POWER_REG4C, 0x00);
ret |= es7210_update_reg_bit(ES7210_MIC3_GAIN_REG45, 0x10, 0x10);
}
if (mic_select & ES7210_INPUT_MIC4) {
ESP_LOGI(TAG, "Enable ES7210_INPUT_MIC4");
ret |= es7210_update_reg_bit(ES7210_CLOCK_OFF_REG01, 0x15, 0x00);
ret |= es7210_write_reg(ES7210_MIC34_POWER_REG4C, 0x00);
ret |= es7210_update_reg_bit(ES7210_MIC4_GAIN_REG46, 0x10, 0x10);
}
} else {
ESP_LOGE(TAG, "Microphone selection error");
return ESP_FAIL;
}
return ret;
}
esp_err_t es7210_adc_init(TwoWire *tw, audio_hal_codec_config_t *codec_cfg)
{
esp_err_t ret = ESP_OK;
es7210wire = tw;
ret |= es7210_write_reg(ES7210_RESET_REG00, 0xff);
ret |= es7210_write_reg(ES7210_RESET_REG00, 0x41);
ret |= es7210_write_reg(ES7210_CLOCK_OFF_REG01, 0x1f);
ret |= es7210_write_reg(ES7210_TIME_CONTROL0_REG09, 0x30); /* Set chip state cycle */
ret |= es7210_write_reg(ES7210_TIME_CONTROL1_REG0A, 0x30); /* Set power on state cycle */
// ret |= es7210_write_reg(ES7210_ADC12_HPF2_REG23, 0x2a); /* Quick setup */
// ret |= es7210_write_reg(ES7210_ADC12_HPF1_REG22, 0x0a);
// ret |= es7210_write_reg(ES7210_ADC34_HPF2_REG20, 0x0a);
// ret |= es7210_write_reg(ES7210_ADC34_HPF1_REG21, 0x2a);
/* Set master/slave audio interface */
audio_hal_codec_i2s_iface_t *i2s_cfg = & (codec_cfg->i2s_iface);
switch (i2s_cfg->mode) {
case AUDIO_HAL_MODE_MASTER: /* MASTER MODE */
ESP_LOGI(TAG, "ES7210 in Master mode");
// ret |= es7210_update_reg_bit(ES7210_MODE_CONFIG_REG08, 0x01, 0x01);
ret |= es7210_write_reg(ES7210_MODE_CONFIG_REG08, 0x20);
/* Select clock source for internal mclk */
switch (get_es7210_mclk_src()) {
case FROM_PAD_PIN:
ret |= es7210_update_reg_bit(ES7210_MASTER_CLK_REG03, 0x80, 0x00);
break;
case FROM_CLOCK_DOUBLE_PIN:
ret |= es7210_update_reg_bit(ES7210_MASTER_CLK_REG03, 0x80, 0x80);
break;
default:
ret |= es7210_update_reg_bit(ES7210_MASTER_CLK_REG03, 0x80, 0x00);
break;
}
break;
case AUDIO_HAL_MODE_SLAVE: /* SLAVE MODE */
ESP_LOGI(TAG, "ES7210 in Slave mode");
break;
default:
break;
}
ret |= es7210_write_reg(ES7210_ANALOG_REG40, 0xC3); /* Select power off analog, vdda = 3.3V, close vx20ff, VMID select 5KΩ start */
ret |= es7210_write_reg(ES7210_MIC12_BIAS_REG41, 0x70); /* Select 2.87v */
ret |= es7210_write_reg(ES7210_MIC34_BIAS_REG42, 0x70); /* Select 2.87v */
ret |= es7210_write_reg(ES7210_OSR_REG07, 0x20);
ret |= es7210_write_reg(ES7210_MAINCLK_REG02, 0xc1); /* Set the frequency division coefficient and use dll except clock doubler, and need to set 0xc1 to clear the state */
ret |= es7210_config_sample(i2s_cfg->samples);
ret |= es7210_mic_select(mic_select);
ret |= es7210_adc_set_gain_all(GAIN_0DB);
return ESP_OK;
}
esp_err_t es7210_adc_deinit()
{
return ESP_OK;
}
esp_err_t es7210_config_fmt(audio_hal_iface_format_t fmt)
{
esp_err_t ret = ESP_OK;
uint8_t adc_iface = 0;
adc_iface = es7210_read_reg(ES7210_SDP_INTERFACE1_REG11);
adc_iface &= 0xfc;
switch (fmt) {
case AUDIO_HAL_I2S_NORMAL:
ESP_LOGD(TAG, "ES7210 in I2S Format");
adc_iface |= 0x00;
break;
case AUDIO_HAL_I2S_LEFT:
case AUDIO_HAL_I2S_RIGHT:
ESP_LOGD(TAG, "ES7210 in LJ Format");
adc_iface |= 0x01;
break;
case AUDIO_HAL_I2S_DSP:
if (I2S_DSP_MODE_A) {
ESP_LOGD(TAG, "ES7210 in DSP-A Format");
adc_iface |= 0x03;
} else {
ESP_LOGD(TAG, "ES7210 in DSP-B Format");
adc_iface |= 0x13;
}
break;
default:
adc_iface &= 0xfc;
break;
}
ret |= es7210_write_reg(ES7210_SDP_INTERFACE1_REG11, adc_iface);
/* Force ADC1/2 output to SDOUT1 and ADC3/4 output to SDOUT2 */
ret |= es7210_write_reg(ES7210_SDP_INTERFACE2_REG12, 0x00);
return ret;
}
esp_err_t es7210_set_bits(audio_hal_iface_bits_t bits)
{
esp_err_t ret = ESP_OK;
uint8_t adc_iface = 0;
adc_iface = es7210_read_reg(ES7210_SDP_INTERFACE1_REG11);
adc_iface &= 0x1f;
switch (bits) {
case AUDIO_HAL_BIT_LENGTH_16BITS:
adc_iface |= 0x60;
break;
case AUDIO_HAL_BIT_LENGTH_24BITS:
adc_iface |= 0x00;
break;
case AUDIO_HAL_BIT_LENGTH_32BITS:
adc_iface |= 0x80;
break;
default:
adc_iface |= 0x60;
break;
}
ret |= es7210_write_reg(ES7210_SDP_INTERFACE1_REG11, adc_iface);
return ret;
}
esp_err_t es7210_adc_config_i2s(audio_hal_codec_mode_t mode, audio_hal_codec_i2s_iface_t *iface)
{
esp_err_t ret = ESP_OK;
ret |= es7210_set_bits(iface->bits);
ret |= es7210_config_fmt(iface->fmt);
ret |= es7210_config_sample(iface->samples);
return ret;
}
esp_err_t es7210_start(uint8_t clock_reg_value)
{
esp_err_t ret = ESP_OK;
ret |= es7210_write_reg(ES7210_CLOCK_OFF_REG01, clock_reg_value);
ret |= es7210_write_reg(ES7210_POWER_DOWN_REG06, 0x00);
// ret |= es7210_write_reg(ES7210_ANALOG_REG40, 0x40);
ret |= es7210_write_reg(ES7210_MIC1_POWER_REG47, 0x00);
ret |= es7210_write_reg(ES7210_MIC2_POWER_REG48, 0x00);
ret |= es7210_write_reg(ES7210_MIC3_POWER_REG49, 0x00);
ret |= es7210_write_reg(ES7210_MIC4_POWER_REG4A, 0x00);
ret |= es7210_mic_select(mic_select);
return ret;
}
esp_err_t es7210_stop(void)
{
esp_err_t ret = ESP_OK;
ret |= es7210_write_reg(ES7210_MIC1_POWER_REG47, 0xff);
ret |= es7210_write_reg(ES7210_MIC2_POWER_REG48, 0xff);
ret |= es7210_write_reg(ES7210_MIC3_POWER_REG49, 0xff);
ret |= es7210_write_reg(ES7210_MIC4_POWER_REG4A, 0xff);
ret |= es7210_write_reg(ES7210_MIC12_POWER_REG4B,0xff);
ret |= es7210_write_reg(ES7210_MIC34_POWER_REG4C, 0xff);
// ret |= es7210_write_reg(ES7210_ANALOG_REG40, 0xc0);
ret |= es7210_write_reg(ES7210_CLOCK_OFF_REG01, 0x7f);
ret |= es7210_write_reg(ES7210_POWER_DOWN_REG06, 0x07);
return ret;
}
esp_err_t es7210_adc_ctrl_state(audio_hal_codec_mode_t mode, audio_hal_ctrl_t ctrl_state)
{
static uint8_t regv;
esp_err_t ret = ESP_OK;
// ESP_LOGW(TAG, "ES7210 only supports ADC mode");
ret = es7210_read_reg(ES7210_CLOCK_OFF_REG01);
if ((ret != 0x7f) && (ret != 0xff)) {
regv = es7210_read_reg(ES7210_CLOCK_OFF_REG01);
}
if (ctrl_state == AUDIO_HAL_CTRL_START) {
ESP_LOGI(TAG, "The ES7210_CLOCK_OFF_REG01 value before stop is %x",regv);
ret |= es7210_start(regv);
} else {
ESP_LOGW(TAG, "The codec is about to stop");
regv = es7210_read_reg(ES7210_CLOCK_OFF_REG01);
ret |= es7210_stop();
}
return ESP_OK;
}
esp_err_t es7210_adc_set_gain(es7210_input_mics_t mic_mask, es7210_gain_value_t gain)
{
esp_err_t ret_val = ESP_OK;
if (gain < GAIN_0DB) {
gain = GAIN_0DB;
}
if (gain > GAIN_37_5DB) {
gain = GAIN_37_5DB;
}
if (mic_mask & ES7210_INPUT_MIC1) {
ret_val |= es7210_update_reg_bit(ES7210_MIC1_GAIN_REG43, 0x0f, gain);
}
if (mic_mask & ES7210_INPUT_MIC2) {
ret_val |= es7210_update_reg_bit(ES7210_MIC2_GAIN_REG44, 0x0f, gain);
}
if (mic_mask & ES7210_INPUT_MIC3) {
ret_val |= es7210_update_reg_bit(ES7210_MIC3_GAIN_REG45, 0x0f, gain);
}
if (mic_mask & ES7210_INPUT_MIC4) {
ret_val |= es7210_update_reg_bit(ES7210_MIC4_GAIN_REG46, 0x0f, gain);
}
return ret_val;
}
esp_err_t es7210_adc_set_gain_all(es7210_gain_value_t gain)
{
esp_err_t ret = ESP_OK;
uint32_t max_gain_vaule = 14;
if (gain < 0) {
gain = (es7210_gain_value_t) 0;
} else if (gain > max_gain_vaule) {
gain = (es7210_gain_value_t) max_gain_vaule;
}
ESP_LOGD(TAG, "SET: gain:%d", gain);
if (mic_select & ES7210_INPUT_MIC1) {
ret |= es7210_update_reg_bit(ES7210_MIC1_GAIN_REG43, 0x0f, gain);
}
if (mic_select & ES7210_INPUT_MIC2) {
ret |= es7210_update_reg_bit(ES7210_MIC2_GAIN_REG44, 0x0f, gain);
}
if (mic_select & ES7210_INPUT_MIC3) {
ret |= es7210_update_reg_bit(ES7210_MIC3_GAIN_REG45, 0x0f, gain);
}
if (mic_select & ES7210_INPUT_MIC4) {
ret |= es7210_update_reg_bit(ES7210_MIC4_GAIN_REG46, 0x0f, gain);
}
return ret;
}
esp_err_t es7210_adc_get_gain(es7210_input_mics_t mic_mask, es7210_gain_value_t *gain)
{
int regv = 0;
uint8_t gain_value;
if (mic_mask & ES7210_INPUT_MIC1) {
regv = es7210_read_reg(ES7210_MIC1_GAIN_REG43);
} else if (mic_mask & ES7210_INPUT_MIC2) {
regv = es7210_read_reg(ES7210_MIC2_GAIN_REG44);
} else if (mic_mask & ES7210_INPUT_MIC3) {
regv = es7210_read_reg(ES7210_MIC3_GAIN_REG45);
} else if (mic_mask & ES7210_INPUT_MIC4) {
regv = es7210_read_reg(ES7210_MIC4_GAIN_REG46);
} else {
ESP_LOGE(TAG, "No MIC selected");
return ESP_FAIL;
}
if (regv == ESP_FAIL) {
return regv;
}
gain_value = (regv & 0x0f); /* Retain the last four bits for gain */
*gain = (es7210_gain_value_t) gain_value;
ESP_LOGI(TAG, "GET: gain_value:%d", gain_value);
return ESP_OK;
}
esp_err_t es7210_adc_set_volume(int volume)
{
esp_err_t ret = ESP_OK;
ESP_LOGD(TAG, "ADC can adjust gain");
return ret;
}
esp_err_t es7210_set_mute(bool enable)
{
ESP_LOGD(TAG, "ES7210 SetMute :%d", enable);
return ESP_OK;
}
void es7210_read_all(void)
{
for (int i = 0; i <= 0x4E; i++) {
uint8_t reg = es7210_read_reg(i);
ets_printf("REG:%02x, %02x\n", reg, i);
}
}
#endif

View File

@ -0,0 +1,260 @@
/*
* ESPRESSIF MIT License
*
* Copyright (c) 2021 <ESPRESSIF SYSTEMS (SHANGHAI) CO., LTD>
*
* Permission is hereby granted for use on all ESPRESSIF SYSTEMS products, in which case,
* it is free of charge, to any person obtaining a copy of this software and associated
* documentation files (the "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished
* to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
*/
#ifndef _ES7210_H
#define _ES7210_H
#include "audio_hal.h"
#include <Wire.h>
typedef enum {
ES7210_AD1_AD0_00 = 0x40,
ES7210_AD1_AD0_01 = 0x41,
ES7210_AD1_AD0_10 = 0x42,
ES7210_AD1_AD0_11 = 0x43,
} es7210_address_t;
/* ES7210 address*/
#define ES7210_ADDR ES7210_AD1_AD0_00
#ifdef __cplusplus
extern "C" {
#endif
#define ES7210_RESET_REG00 0x00 /* Reset control */
#define ES7210_CLOCK_OFF_REG01 0x01 /* Used to turn off the ADC clock */
#define ES7210_MAINCLK_REG02 0x02 /* Set ADC clock frequency division */
#define ES7210_MASTER_CLK_REG03 0x03 /* MCLK source $ SCLK division */
#define ES7210_LRCK_DIVH_REG04 0x04 /* lrck_divh */
#define ES7210_LRCK_DIVL_REG05 0x05 /* lrck_divl */
#define ES7210_POWER_DOWN_REG06 0x06 /* power down */
#define ES7210_OSR_REG07 0x07
#define ES7210_MODE_CONFIG_REG08 0x08 /* Set master/slave & channels */
#define ES7210_TIME_CONTROL0_REG09 0x09 /* Set Chip intial state period*/
#define ES7210_TIME_CONTROL1_REG0A 0x0A /* Set Power up state period */
#define ES7210_SDP_INTERFACE1_REG11 0x11 /* Set sample & fmt */
#define ES7210_SDP_INTERFACE2_REG12 0x12 /* Pins state */
#define ES7210_ADC_AUTOMUTE_REG13 0x13 /* Set mute */
#define ES7210_ADC34_MUTERANGE_REG14 0x14 /* Set mute range */
#define ES7210_ADC34_HPF2_REG20 0x20 /* HPF */
#define ES7210_ADC34_HPF1_REG21 0x21
#define ES7210_ADC12_HPF1_REG22 0x22
#define ES7210_ADC12_HPF2_REG23 0x23
#define ES7210_ANALOG_REG40 0x40 /* ANALOG Power */
#define ES7210_MIC12_BIAS_REG41 0x41
#define ES7210_MIC34_BIAS_REG42 0x42
#define ES7210_MIC1_GAIN_REG43 0x43
#define ES7210_MIC2_GAIN_REG44 0x44
#define ES7210_MIC3_GAIN_REG45 0x45
#define ES7210_MIC4_GAIN_REG46 0x46
#define ES7210_MIC1_POWER_REG47 0x47
#define ES7210_MIC2_POWER_REG48 0x48
#define ES7210_MIC3_POWER_REG49 0x49
#define ES7210_MIC4_POWER_REG4A 0x4A
#define ES7210_MIC12_POWER_REG4B 0x4B /* MICBias & ADC & PGA Power */
#define ES7210_MIC34_POWER_REG4C 0x4C
typedef enum {
ES7210_INPUT_MIC1 = 0x01,
ES7210_INPUT_MIC2 = 0x02,
ES7210_INPUT_MIC3 = 0x04,
ES7210_INPUT_MIC4 = 0x08
} es7210_input_mics_t;
typedef enum gain_value{
GAIN_0DB = 0,
GAIN_3DB,
GAIN_6DB,
GAIN_9DB,
GAIN_12DB,
GAIN_15DB,
GAIN_18DB,
GAIN_21DB,
GAIN_24DB,
GAIN_27DB,
GAIN_30DB,
GAIN_33DB,
GAIN_34_5DB,
GAIN_36DB,
GAIN_37_5DB,
} es7210_gain_value_t;
/*
* @brief Initialize ES7210 ADC chip
*
* @param[in] codec_cfg: configuration of ES7210
*
* @return
* - ESP_OK
* - ESP_FAIL
*/
esp_err_t es7210_adc_init(TwoWire *tw, audio_hal_codec_config_t *codec_cfg);
/**
* @brief Deinitialize ES7210 ADC chip
*
* @return
* - ESP_OK
* - ESP_FAIL
*/
esp_err_t es7210_adc_deinit();
/**
* @brief Configure ES7210 ADC mode and I2S interface
*
* @param[in] mode: codec mode
* @param[in] iface: I2S config
*
* @return
* - ESP_FAIL Parameter error
* - ESP_OK Success
*/
esp_err_t es7210_adc_config_i2s(audio_hal_codec_mode_t mode, audio_hal_codec_i2s_iface_t *iface);
/**
* @brief Control ES7210 ADC chip
*
* @param[in] mode: codec mode
* @param[in] ctrl_state: start or stop progress
*
* @return
* - ESP_FAIL Parameter error
* - ESP_OK Success
*/
esp_err_t es7210_adc_ctrl_state(audio_hal_codec_mode_t mode, audio_hal_ctrl_t ctrl_state);
/**
* @brief Set gain of given mask
*
* @param[in] mic_mask Mask of MIC channel
*
* @param[in] gain: gain
*
* gain : value
* GAIN_0DB : 1
* GAIN_3DB : 2
* GAIN_6DB : 3
* ·
* ·
* ·
* GAIN_30DB : 10
* GAIN_33DB : 11
* GAIN_34_5DB : 12
* GAIN_36DB : 13
* GAIN_37_5DB : 14
*
* @return
* - ESP_OK
* - ESP_FAIL
*/
esp_err_t es7210_adc_set_gain(es7210_input_mics_t mic_mask, es7210_gain_value_t gain);
/**
* @brief Set gain (Note: the enabled microphone sets the same gain)
*
* @param[in] gain: gain
*
* gain : value
* GAIN_0DB : 1
* GAIN_3DB : 2
* GAIN_6DB : 3
* ·
* ·
* ·
* GAIN_30DB : 10
* GAIN_33DB : 11
* GAIN_34_5DB : 12
* GAIN_36DB : 13
* GAIN_37_5DB : 14
*
* @return
* - ESP_OK
* - ESP_FAIL
*/
esp_err_t es7210_adc_set_gain_all(es7210_gain_value_t gain);
/**
* @brief Get MIC gain
*
* @param mic_mask Selected MIC
* @param gain Pointer to `es7210_gain_value_t`
* @return
* - ESP_OK
* - ESP_FAIL
*/
esp_err_t es7210_adc_get_gain(es7210_input_mics_t mic_mask, es7210_gain_value_t *gain);
/**
* @brief Set volume
*
* @param[in] volume: volume
*
* @return
* - ESP_OK
*/
esp_err_t es7210_adc_set_volume(int volume);
/**
* @brief Set ES7210 ADC mute status
*
* @return
* - ESP_FAIL
* - ESP_OK
*/
esp_err_t es7210_set_mute(bool enable);
/**
* @brief Select ES7210 mic
*
* @param[in] mic: mics
*
* @return
* - ESP_FAIL
* - ESP_OK
*/
esp_err_t es7210_mic_select(es7210_input_mics_t mic);
/**
* @brief Read regs of ES7210
*
* @param[in] reg_addr: reg_addr
*
* @return
* - ESP_FAIL
* - ESP_OK
*/
int es7210_read_reg(uint8_t reg_addr);
/**
* @brief Read all regs of ES7210
*/
void es7210_read_all(void);
#ifdef __cplusplus
}
#endif
#endif /* _ES7210_H_ */

View File

@ -0,0 +1,443 @@
/*
* SPDX-FileCopyrightText: 2015-2022 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <string.h>
#include "es8311.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_err.h"
#include "esp_check.h"
#include "es8311_reg.h"
#include "esp32-hal-i2c.h"
typedef struct {
unsigned int port;
uint16_t dev_addr;
} es8311_dev_t;
/*
* Clock coefficient structure
*/
struct _coeff_div {
uint32_t mclk; /* mclk frequency */
uint32_t rate; /* sample rate */
uint8_t pre_div; /* the pre divider with range from 1 to 8 */
uint8_t pre_multi; /* the pre multiplier with 0: 1x, 1: 2x, 2: 4x, 3: 8x selection */
uint8_t adc_div; /* adcclk divider */
uint8_t dac_div; /* dacclk divider */
uint8_t fs_mode; /* double speed or single speed, =0, ss, =1, ds */
uint8_t lrck_h; /* adclrck divider and daclrck divider */
uint8_t lrck_l;
uint8_t bclk_div; /* sclk divider */
uint8_t adc_osr; /* adc osr */
uint8_t dac_osr; /* dac osr */
};
/* codec hifi mclk clock divider coefficients */
static const struct _coeff_div coeff_div[] = {
/*!<mclk rate pre_div mult adc_div dac_div fs_mode lrch lrcl bckdiv osr */
/* 8k */
{12288000, 8000, 0x06, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 8000, 0x03, 0x01, 0x03, 0x03, 0x00, 0x05, 0xff, 0x18, 0x10, 0x10},
{16384000, 8000, 0x08, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{8192000, 8000, 0x04, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 8000, 0x03, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{4096000, 8000, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 8000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2048000, 8000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 8000, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1024000, 8000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 11.025k */
{11289600, 11025, 0x04, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{5644800, 11025, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2822400, 11025, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1411200, 11025, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 12k */
{12288000, 12000, 0x04, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 12000, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 12000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 12000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 16k */
{12288000, 16000, 0x03, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 16000, 0x03, 0x01, 0x03, 0x03, 0x00, 0x02, 0xff, 0x0c, 0x10, 0x10},
{16384000, 16000, 0x04, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{8192000, 16000, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 16000, 0x03, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{4096000, 16000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 16000, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2048000, 16000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 16000, 0x03, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1024000, 16000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 22.05k */
{11289600, 22050, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{5644800, 22050, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2822400, 22050, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1411200, 22050, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{705600, 22050, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 24k */
{12288000, 24000, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 24000, 0x03, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 24000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 24000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 24000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 32k */
{12288000, 32000, 0x03, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 32000, 0x03, 0x02, 0x03, 0x03, 0x00, 0x02, 0xff, 0x0c, 0x10, 0x10},
{16384000, 32000, 0x02, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{8192000, 32000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 32000, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{4096000, 32000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 32000, 0x03, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2048000, 32000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 32000, 0x03, 0x03, 0x01, 0x01, 0x01, 0x00, 0x7f, 0x02, 0x10, 0x10},
{1024000, 32000, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 44.1k */
{11289600, 44100, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{5644800, 44100, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2822400, 44100, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1411200, 44100, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 48k */
{12288000, 48000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 48000, 0x03, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 48000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 48000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 48000, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
/* 64k */
{12288000, 64000, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 64000, 0x03, 0x02, 0x03, 0x03, 0x01, 0x01, 0x7f, 0x06, 0x10, 0x10},
{16384000, 64000, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{8192000, 64000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 64000, 0x01, 0x02, 0x03, 0x03, 0x01, 0x01, 0x7f, 0x06, 0x10, 0x10},
{4096000, 64000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 64000, 0x01, 0x03, 0x03, 0x03, 0x01, 0x01, 0x7f, 0x06, 0x10, 0x10},
{2048000, 64000, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 64000, 0x01, 0x03, 0x01, 0x01, 0x01, 0x00, 0xbf, 0x03, 0x18, 0x18},
{1024000, 64000, 0x01, 0x03, 0x01, 0x01, 0x01, 0x00, 0x7f, 0x02, 0x10, 0x10},
/* 88.2k */
{11289600, 88200, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{5644800, 88200, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{2822400, 88200, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1411200, 88200, 0x01, 0x03, 0x01, 0x01, 0x01, 0x00, 0x7f, 0x02, 0x10, 0x10},
/* 96k */
{12288000, 96000, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{18432000, 96000, 0x03, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{6144000, 96000, 0x01, 0x02, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{3072000, 96000, 0x01, 0x03, 0x01, 0x01, 0x00, 0x00, 0xff, 0x04, 0x10, 0x10},
{1536000, 96000, 0x01, 0x03, 0x01, 0x01, 0x01, 0x00, 0x7f, 0x02, 0x10, 0x10},
};
static const char *TAG = "ES8311";
static inline esp_err_t es8311_write_reg(es8311_handle_t dev, uint8_t reg_addr, uint8_t data)
{
es8311_dev_t *es = (es8311_dev_t *) dev;
const uint8_t write_buf[2] = {reg_addr, data};
return i2cWrite(es->port, es->dev_addr, write_buf, sizeof(write_buf), 1000);
}
static inline esp_err_t es8311_read_reg(es8311_handle_t dev, uint8_t reg_addr, uint8_t *reg_value)
{
es8311_dev_t *es = (es8311_dev_t *) dev;
size_t readCount = 0;
return i2cWriteReadNonStop(es->port, es->dev_addr, &reg_addr, 1, reg_value, 1, 1000, &readCount);
}
/*
* look for the coefficient in coeff_div[] table
*/
static int get_coeff(uint32_t mclk, uint32_t rate)
{
for (int i = 0; i < (sizeof(coeff_div) / sizeof(coeff_div[0])); i++) {
if (coeff_div[i].rate == rate && coeff_div[i].mclk == mclk) {
return i;
}
}
return -1;
}
esp_err_t es8311_sample_frequency_config(es8311_handle_t dev, int mclk_frequency, int sample_frequency)
{
uint8_t regv;
/* Get clock coefficients from coefficient table */
int coeff = get_coeff(mclk_frequency, sample_frequency);
if (coeff < 0) {
ESP_LOGE(TAG, "Unable to configure sample rate %dHz with %dHz MCLK", sample_frequency, mclk_frequency);
return ESP_ERR_INVALID_ARG;
}
const struct _coeff_div *const selected_coeff = &coeff_div[coeff];
/* register 0x02 */
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_CLK_MANAGER_REG02, &regv), TAG, "I2C read/write error");
regv &= 0x07;
regv |= (selected_coeff->pre_div - 1) << 5;
regv |= selected_coeff->pre_multi << 3;
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG02, regv), TAG, "I2C read/write error");
/* register 0x03 */
const uint8_t reg03 = (selected_coeff->fs_mode << 6) | selected_coeff->adc_osr;
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG03, reg03), TAG, "I2C read/write error");
/* register 0x04 */
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG04, selected_coeff->dac_osr), TAG, "I2C read/write error");
/* register 0x05 */
const uint8_t reg05 = ((selected_coeff->adc_div - 1) << 4) | (selected_coeff->dac_div - 1);
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG05, reg05), TAG, "I2C read/write error");
/* register 0x06 */
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_CLK_MANAGER_REG06, &regv), TAG, "I2C read/write error");
regv &= 0xE0;
if (selected_coeff->bclk_div < 19) {
regv |= (selected_coeff->bclk_div - 1) << 0;
} else {
regv |= (selected_coeff->bclk_div) << 0;
}
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG06, regv), TAG, "I2C read/write error");
/* register 0x07 */
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_CLK_MANAGER_REG07, &regv), TAG, "I2C read/write error");
regv &= 0xC0;
regv |= selected_coeff->lrck_h << 0;
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG07, regv), TAG, "I2C read/write error");
/* register 0x08 */
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG08, selected_coeff->lrck_l), TAG, "I2C read/write error");
return ESP_OK;
}
static esp_err_t es8311_clock_config(es8311_handle_t dev, const es8311_clock_config_t *const clk_cfg, es8311_resolution_t res)
{
uint8_t reg06;
uint8_t reg01 = 0x3F; // Enable all clocks
int mclk_hz;
/* Select clock source for internal MCLK and determine its frequency */
if (clk_cfg->mclk_from_mclk_pin) {
mclk_hz = clk_cfg->mclk_frequency;
} else {
mclk_hz = clk_cfg->sample_frequency * (int)res * 2;
reg01 |= BIT(7); // Select BCLK (a.k.a. SCK) pin
}
if (clk_cfg->mclk_inverted) {
reg01 |= BIT(6); // Invert MCLK pin
}
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG01, reg01), TAG, "I2C read/write error");
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_CLK_MANAGER_REG06, &reg06), TAG, "I2C read/write error");
if (clk_cfg->sclk_inverted) {
reg06 |= BIT(5);
} else {
reg06 &= ~BIT(5);
}
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_CLK_MANAGER_REG06, reg06), TAG, "I2C read/write error");
/* Configure clock dividers */
return es8311_sample_frequency_config(dev, mclk_hz, clk_cfg->sample_frequency);
}
static esp_err_t es8311_resolution_config(const es8311_resolution_t res, uint8_t *reg)
{
switch (res) {
case ES8311_RESOLUTION_16:
*reg |= (3 << 2);
break;
case ES8311_RESOLUTION_18:
*reg |= (2 << 2);
break;
case ES8311_RESOLUTION_20:
*reg |= (1 << 2);
break;
case ES8311_RESOLUTION_24:
*reg |= (0 << 2);
break;
case ES8311_RESOLUTION_32:
*reg |= (4 << 2);
break;
default:
return ESP_ERR_INVALID_ARG;
}
return ESP_OK;
}
static esp_err_t es8311_fmt_config(es8311_handle_t dev, const es8311_resolution_t res_in, const es8311_resolution_t res_out)
{
uint8_t reg09 = 0; // SDP In
uint8_t reg0a = 0; // SDP Out
ESP_LOGI(TAG, "ES8311 in Slave mode and I2S format");
uint8_t reg00;
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_RESET_REG00, &reg00), TAG, "I2C read/write error");
reg00 &= 0xBF;
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_RESET_REG00, reg00), TAG, "I2C read/write error"); // Slave serial port - default
/* Setup SDP In and Out resolution */
es8311_resolution_config(res_in, &reg09);
es8311_resolution_config(res_out, &reg0a);
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SDPIN_REG09, reg09), TAG, "I2C read/write error");
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SDPOUT_REG0A, reg0a), TAG, "I2C read/write error");
return ESP_OK;
}
esp_err_t es8311_microphone_config(es8311_handle_t dev, bool digital_mic)
{
uint8_t reg14 = 0x1A; // enable analog MIC and max PGA gain
/* PDM digital microphone enable or disable */
if (digital_mic) {
reg14 |= BIT(6);
}
es8311_write_reg(dev, ES8311_ADC_REG17, 0xC8); // Set ADC gain @todo move this to ADC config section
return es8311_write_reg(dev, ES8311_SYSTEM_REG14, reg14);
}
esp_err_t es8311_init(es8311_handle_t dev, const es8311_clock_config_t *const clk_cfg, const es8311_resolution_t res_in, const es8311_resolution_t res_out)
{
ESP_RETURN_ON_FALSE(
(clk_cfg->sample_frequency >= 8000) && (clk_cfg->sample_frequency <= 96000),
ESP_ERR_INVALID_ARG, TAG, "ES8311 init needs frequency in interval [8000; 96000] Hz"
);
if (!clk_cfg->mclk_from_mclk_pin) {
ESP_RETURN_ON_FALSE(res_out == res_in, ESP_ERR_INVALID_ARG, TAG, "Resolution IN/OUT must be equal if MCLK is taken from SCK pin");
}
/* Reset ES8311 to its default */
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_RESET_REG00, 0x1F), TAG, "I2C read/write error");
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_RESET_REG00, 0x00), TAG, "I2C read/write error");
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_RESET_REG00, 0x80), TAG, "I2C read/write error"); // Power-on command
/* Setup clock: source, polarity and clock dividers */
ESP_RETURN_ON_ERROR(es8311_clock_config(dev, clk_cfg, res_out), TAG, "");
/* Setup audio format (fmt): master/slave, resolution, I2S */
ESP_RETURN_ON_ERROR(es8311_fmt_config(dev, res_in, res_out), TAG, "");
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SYSTEM_REG0D, 0x01), TAG, "I2C read/write error"); // Power up analog circuitry - NOT default
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SYSTEM_REG0E, 0x02), TAG, "I2C read/write error"); // Enable analog PGA, enable ADC modulator - NOT default
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SYSTEM_REG12, 0x00), TAG, "I2C read/write error"); // power-up DAC - NOT default
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_SYSTEM_REG13, 0x10), TAG, "I2C read/write error"); // Enable output to HP drive - NOT default
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_ADC_REG1C, 0x6A), TAG, "I2C read/write error"); // ADC Equalizer bypass, cancel DC offset in digital domain
ESP_RETURN_ON_ERROR(es8311_write_reg(dev, ES8311_DAC_REG37, 0x08), TAG, "I2C read/write error"); // Bypass DAC equalizer - NOT default
return ESP_OK;
}
void es8311_delete(es8311_handle_t dev)
{
free(dev);
}
esp_err_t es8311_voice_volume_set(es8311_handle_t dev, int volume, int *volume_set)
{
if (volume < 0) {
volume = 0;
} else if (volume > 100) {
volume = 100;
}
int reg32;
if (volume == 0) {
reg32 = 0;
} else {
reg32 = ((volume) * 256 / 100) - 1;
}
// provide user with real volume set
if (volume_set != NULL) {
*volume_set = volume;
}
return es8311_write_reg(dev, ES8311_DAC_REG32, reg32);
}
esp_err_t es8311_voice_volume_get(es8311_handle_t dev, int *volume)
{
uint8_t reg32;
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_DAC_REG32, &reg32), TAG, "I2C read/write error");
if (reg32 == 0) {
*volume = 0;
} else {
*volume = ((reg32 * 100) / 256) + 1;
}
return ESP_OK;
}
esp_err_t es8311_voice_mute(es8311_handle_t dev, bool mute)
{
uint8_t reg31;
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_DAC_REG31, &reg31), TAG, "I2C read/write error");
if (mute) {
reg31 |= BIT(6) | BIT(5);
} else {
reg31 &= ~(BIT(6) | BIT(5));
}
return es8311_write_reg(dev, ES8311_DAC_REG31, reg31);
}
esp_err_t es8311_microphone_gain_set(es8311_handle_t dev, es8311_mic_gain_t gain_db)
{
return es8311_write_reg(dev, ES8311_ADC_REG16, gain_db); // ADC gain scale up
}
esp_err_t es8311_voice_fade(es8311_handle_t dev, const es8311_fade_t fade)
{
uint8_t reg37;
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_DAC_REG37, &reg37), TAG, "I2C read/write error");
reg37 &= 0x0F;
reg37 |= (fade << 4);
return es8311_write_reg(dev, ES8311_DAC_REG37, reg37);
}
esp_err_t es8311_microphone_fade(es8311_handle_t dev, const es8311_fade_t fade)
{
uint8_t reg15;
ESP_RETURN_ON_ERROR(es8311_read_reg(dev, ES8311_ADC_REG15, &reg15), TAG, "I2C read/write error");
reg15 &= 0x0F;
reg15 |= (fade << 4);
return es8311_write_reg(dev, ES8311_ADC_REG15, reg15);
}
void es8311_register_dump(es8311_handle_t dev)
{
for (int reg = 0; reg < 0x4A; reg++) {
uint8_t value;
ESP_ERROR_CHECK(es8311_read_reg(dev, reg, &value));
printf("REG:%02x: %02x", reg, value);
}
}
es8311_handle_t es8311_create(const unsigned int port, const uint16_t dev_addr)
{
es8311_dev_t *sensor = (es8311_dev_t *) calloc(1, sizeof(es8311_dev_t));
sensor->port = port;
sensor->dev_addr = dev_addr;
return (es8311_handle_t) sensor;
}

View File

@ -0,0 +1,227 @@
/*
* SPDX-FileCopyrightText: 2015-2022 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @file
* @brief ES8311 driver
*/
#pragma once
#include "esp_types.h"
#include "esp_err.h"
/* ES8311 address: CE pin low - 0x18, CE pin high - 0x19 */
#define ES8311_ADDRRES_0 0x18u // Leaving this here for backward compatibility
#define ES8311_ADDRESS_0 0x18u
#define ES8311_ADDRESS_1 0x19u
#ifdef __cplusplus
extern "C" {
#endif
typedef void *es8311_handle_t;
typedef enum {
ES8311_MIC_GAIN_MIN = -1,
ES8311_MIC_GAIN_0DB,
ES8311_MIC_GAIN_6DB,
ES8311_MIC_GAIN_12DB,
ES8311_MIC_GAIN_18DB,
ES8311_MIC_GAIN_24DB,
ES8311_MIC_GAIN_30DB,
ES8311_MIC_GAIN_36DB,
ES8311_MIC_GAIN_42DB,
ES8311_MIC_GAIN_MAX
} es8311_mic_gain_t;
typedef enum {
ES8311_FADE_OFF = 0,
ES8311_FADE_4LRCK, // 4LRCK means ramp 0.25dB/4LRCK
ES8311_FADE_8LRCK,
ES8311_FADE_16LRCK,
ES8311_FADE_32LRCK,
ES8311_FADE_64LRCK,
ES8311_FADE_128LRCK,
ES8311_FADE_256LRCK,
ES8311_FADE_512LRCK,
ES8311_FADE_1024LRCK,
ES8311_FADE_2048LRCK,
ES8311_FADE_4096LRCK,
ES8311_FADE_8192LRCK,
ES8311_FADE_16384LRCK,
ES8311_FADE_32768LRCK,
ES8311_FADE_65536LRCK
} es8311_fade_t;
typedef enum es8311_resolution_t {
ES8311_RESOLUTION_16 = 16,
ES8311_RESOLUTION_18 = 18,
ES8311_RESOLUTION_20 = 20,
ES8311_RESOLUTION_24 = 24,
ES8311_RESOLUTION_32 = 32
} es8311_resolution_t;
typedef struct es8311_clock_config_t {
bool mclk_inverted;
bool sclk_inverted;
bool mclk_from_mclk_pin; // true: from MCLK pin (pin no. 2), false: from SCLK pin (pin no. 6)
int mclk_frequency; // This parameter is ignored if MCLK is taken from SCLK pin
int sample_frequency; // in Hz
} es8311_clock_config_t;
/**
* @brief Initialize ES8311
*
* There are two ways of providing Master Clock (MCLK) signal to ES8311 in Slave Mode:
* 1. From MCLK pin:
* For flexible scenarios. A clock signal from I2S master is routed to MCLK pin.
* Its frequency must be defined in clk_cfg->mclk_frequency parameter.
* 2. From SCLK pin:
* For simpler scenarios. ES8311 takes its clock from SCK pin. MCLK pin does not have to be connected.
* In this case, res_in must equal res_out; clk_cfg->mclk_frequency parameter is ignored
* and MCLK is calculated as MCLK = clk_cfg->sample_frequency * res_out * 2.
* Not all sampling frequencies are supported in this mode.
*
* @param dev ES8311 handle
* @param[in] clk_cfg Clock configuration
* @param[in] res_in Input serial port resolution
* @param[in] res_out Output serial port resolution
* @return
* - ESP_OK success
* - ESP_ERR_INVALID_ARG Sample frequency or resolution invalid
* - Else fail
*/
esp_err_t es8311_init(es8311_handle_t dev, const es8311_clock_config_t *const clk_cfg, const es8311_resolution_t res_in,
const es8311_resolution_t res_out);
/**
* @brief Set output volume
*
* Volume paramter out of <0, 100> interval will be truncated.
*
* @param dev ES8311 handle
* @param[in] volume Set volume (0 ~ 100)
* @param[out] volume_set Volume that was set. Same as volume, unless volume is outside of <0, 100> interval.
* This parameter can be set to NULL, if user does not need this information.
*
* @return
* - ESP_OK success
* - Else fail
*/
esp_err_t es8311_voice_volume_set(es8311_handle_t dev, int volume, int *volume_set);
/**
* @brief Get output volume
*
* @param dev ES8311 handle
* @param[out] volume get volume (0 ~ 100)
*
* @return
* - ESP_OK success
* - Else fail
*/
esp_err_t es8311_voice_volume_get(es8311_handle_t dev, int *volume);
/**
* @brief Print out ES8311 register content
*
* @param dev ES8311 handle
*/
void es8311_register_dump(es8311_handle_t dev);
/**
* @brief Mute ES8311 output
*
* @param dev ES8311 handle
* @param[in] enable true: mute, false: don't mute
* @return
* - ESP_OK success
* - Else fail
*/
esp_err_t es8311_voice_mute(es8311_handle_t dev, bool enable);
/**
* @brief Set Microphone gain
*
* @param dev ES8311 handle
* @param[in] gain_db Microphone gain
* @return
* - ESP_OK success
* - Else fail
*/
esp_err_t es8311_microphone_gain_set(es8311_handle_t dev, es8311_mic_gain_t gain_db);
/**
* @brief Configure microphone
*
* @param dev ES8311 handle
* @param[in] digital_mic Set to true for digital microphone
* @return
* - ESP_OK success
* - Else fail
*/
esp_err_t es8311_microphone_config(es8311_handle_t dev, bool digital_mic);
/**
* @brief Configure sampling frequency
*
* @note This function is called by es8311_init().
* Call this function explicitly only if you want to change sample frequency during runtime.
* @param dev ES8311 handle
* @param[in] mclk_frequency MCLK frequency in [Hz] (MCLK or SCLK pin, depending on bit register01[7])
* @param[in] sample_frequency Required sample frequency in [Hz], e.g. 44100, 22050...
* @return
* - ESP_OK success
* - ESP_ERR_INVALID_ARG cannot set clock dividers for given MCLK and sampling frequency
* - Else I2C read/write error
*/
esp_err_t es8311_sample_frequency_config(es8311_handle_t dev, int mclk_frequency, int sample_frequency);
/**
* @brief Configure fade in/out for ADC: voice
*
* @param dev ES8311 handle
* @param[in] fade Fade ramp rate
* @return
* - ESP_OK success
* - Else I2C read/write error
*/
esp_err_t es8311_voice_fade(es8311_handle_t dev, const es8311_fade_t fade);
/**
* @brief Configure fade in/out for DAC: microphone
*
* @param dev ES8311 handle
* @param[in] fade Fade ramp rate
* @return
* - ESP_OK success
* - Else I2C read/write error
*/
esp_err_t es8311_microphone_fade(es8311_handle_t dev, const es8311_fade_t fade);
/**
* @brief Create ES8311 object and return its handle
*
* @param[in] port I2C port number
* @param[in] dev_addr I2C device address of ES8311
*
* @return
* - NULL Fail
* - Others Success
*/
es8311_handle_t es8311_create(const unsigned int port, const uint16_t dev_addr);
/**
* @brief Delete ES8311 object
*
* @param dev ES8311 handle
*/
void es8311_delete(es8311_handle_t dev);
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,76 @@
/*
* SPDX-FileCopyrightText: 2015-2021 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
/*
* ES8311_REGISTER NAME_REG_REGISTER ADDRESS
*/
#define ES8311_RESET_REG00 0x00 /*reset digital,csm,clock manager etc.*/
/*
* Clock Scheme Register definition
*/
#define ES8311_CLK_MANAGER_REG01 0x01 /* select clk src for mclk, enable clock for codec */
#define ES8311_CLK_MANAGER_REG02 0x02 /* clk divider and clk multiplier */
#define ES8311_CLK_MANAGER_REG03 0x03 /* adc fsmode and osr */
#define ES8311_CLK_MANAGER_REG04 0x04 /* dac osr */
#define ES8311_CLK_MANAGER_REG05 0x05 /* clk divier for adc and dac */
#define ES8311_CLK_MANAGER_REG06 0x06 /* bclk inverter and divider */
#define ES8311_CLK_MANAGER_REG07 0x07 /* tri-state, lrck divider */
#define ES8311_CLK_MANAGER_REG08 0x08 /* lrck divider */
/*
* SDP
*/
#define ES8311_SDPIN_REG09 0x09 /* dac serial digital port */
#define ES8311_SDPOUT_REG0A 0x0A /* adc serial digital port */
/*
* SYSTEM
*/
#define ES8311_SYSTEM_REG0B 0x0B /* system */
#define ES8311_SYSTEM_REG0C 0x0C /* system */
#define ES8311_SYSTEM_REG0D 0x0D /* system, power up/down */
#define ES8311_SYSTEM_REG0E 0x0E /* system, power up/down */
#define ES8311_SYSTEM_REG0F 0x0F /* system, low power */
#define ES8311_SYSTEM_REG10 0x10 /* system */
#define ES8311_SYSTEM_REG11 0x11 /* system */
#define ES8311_SYSTEM_REG12 0x12 /* system, Enable DAC */
#define ES8311_SYSTEM_REG13 0x13 /* system */
#define ES8311_SYSTEM_REG14 0x14 /* system, select DMIC, select analog pga gain */
/*
* ADC
*/
#define ES8311_ADC_REG15 0x15 /* ADC, adc ramp rate, dmic sense */
#define ES8311_ADC_REG16 0x16 /* ADC */
#define ES8311_ADC_REG17 0x17 /* ADC, volume */
#define ES8311_ADC_REG18 0x18 /* ADC, alc enable and winsize */
#define ES8311_ADC_REG19 0x19 /* ADC, alc maxlevel */
#define ES8311_ADC_REG1A 0x1A /* ADC, alc automute */
#define ES8311_ADC_REG1B 0x1B /* ADC, alc automute, adc hpf s1 */
#define ES8311_ADC_REG1C 0x1C /* ADC, equalizer, hpf s2 */
/*
* DAC
*/
#define ES8311_DAC_REG31 0x31 /* DAC, mute */
#define ES8311_DAC_REG32 0x32 /* DAC, volume */
#define ES8311_DAC_REG33 0x33 /* DAC, offset */
#define ES8311_DAC_REG34 0x34 /* DAC, drc enable, drc winsize */
#define ES8311_DAC_REG35 0x35 /* DAC, drc maxlevel, minilevel */
#define ES8311_DAC_REG37 0x37 /* DAC, ramprate */
/*
*GPIO
*/
#define ES8311_GPIO_REG44 0x44 /* GPIO, dac2adc for test */
#define ES8311_GP_REG45 0x45 /* GP CONTROL */
/*
* CHIP
*/
#define ES8311_CHD1_REGFD 0xFD /* CHIP ID1 */
#define ES8311_CHD2_REGFE 0xFE /* CHIP ID2 */
#define ES8311_CHVER_REGFF 0xFF /* VERSION */
#define ES8311_CHD1_REGFD 0xFD /* CHIP ID1 */
#define ES8311_MAX_REGISTER 0xFF

View File

@ -0,0 +1,272 @@
#include <Arduino.h>
#include <Wire.h>
#include <math.h>
#include "ESP_I2S.h"
#include "esp_check.h"
#include "Arduino_GFX_Library.h"
#include "pin_config.h"
#include "TouchDrvCSTXXX.hpp"
#include "SensorQMI8658.hpp"
#include "es8311.h"
#include "es7210.h"
static const int DISP_W = 480;
static const int DISP_H = 480;
static const int SAFE = 20;
static const uint32_t SAMPLE_RATE = 16000;
static const size_t REC_SECONDS = 2;
static const size_t AUDIO_BYTES = SAMPLE_RATE * REC_SECONDS * 2 * 2;
static const int I2C_NUM = 0;
I2SClass i2s;
TouchDrvCST92xx touch;
SensorQMI8658 qmi;
IMUdata acc;
Arduino_DataBus *bus = new Arduino_ESP32QSPI(LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3);
Arduino_CO5300 *gfx = new Arduino_CO5300(bus, LCD_RESET, 0, DISP_W, DISP_H, 0, 0, 0, 0);
uint8_t *audioBuf = nullptr;
size_t audioLen = 0;
bool touchIrq = false;
String statusLine = "Ready";
String tiltLine = "Tilt: FLAT";
String audioLine = "Audio: empty";
uint32_t lastTiltMs = 0;
bool micReady = false;
void IRAM_ATTR onTouchInt() { touchIrq = true; }
esp_err_t codecInit() {
es8311_handle_t esHandle = es8311_create(I2C_NUM, ES8311_ADDRRES_0);
ESP_RETURN_ON_FALSE(esHandle, ESP_FAIL, "AV", "es8311 create failed");
const es8311_clock_config_t clk = {
.mclk_inverted = false,
.sclk_inverted = false,
.mclk_from_mclk_pin = true,
.mclk_frequency = SAMPLE_RATE * 256,
.sample_frequency = SAMPLE_RATE,
};
ESP_ERROR_CHECK(es8311_init(esHandle, &clk, ES8311_RESOLUTION_16, ES8311_RESOLUTION_16));
ESP_RETURN_ON_ERROR(es8311_sample_frequency_config(esHandle, clk.mclk_frequency, clk.sample_frequency), "AV", "clock cfg failed");
ESP_RETURN_ON_ERROR(es8311_microphone_config(esHandle, false), "AV", "mic cfg failed");
ESP_RETURN_ON_ERROR(es8311_voice_volume_set(esHandle, 90, NULL), "AV", "volume failed");
ESP_RETURN_ON_ERROR(es8311_microphone_gain_set(esHandle, (es8311_mic_gain_t)6), "AV", "mic gain failed");
return ESP_OK;
}
esp_err_t micInit() {
audio_hal_codec_config_t cfg = {
.adc_input = AUDIO_HAL_ADC_INPUT_ALL,
.codec_mode = AUDIO_HAL_CODEC_MODE_ENCODE,
.i2s_iface = {
.mode = AUDIO_HAL_MODE_SLAVE,
.fmt = AUDIO_HAL_I2S_NORMAL,
.samples = AUDIO_HAL_16K_SAMPLES,
.bits = AUDIO_HAL_BIT_LENGTH_16BITS,
},
};
esp_err_t ret = ESP_OK;
ret |= es7210_adc_init(&Wire, &cfg);
ret |= es7210_adc_config_i2s(cfg.codec_mode, &cfg.i2s_iface);
ret |= es7210_adc_set_gain(
(es7210_input_mics_t)(ES7210_INPUT_MIC1 | ES7210_INPUT_MIC2),
(es7210_gain_value_t)GAIN_24DB);
ret |= es7210_adc_set_gain(
(es7210_input_mics_t)(ES7210_INPUT_MIC3 | ES7210_INPUT_MIC4),
(es7210_gain_value_t)GAIN_24DB);
ret |= es7210_adc_ctrl_state(cfg.codec_mode, AUDIO_HAL_CTRL_START);
return ret;
}
void drawStaticUi() {
gfx->fillScreen(RGB565_BLACK);
gfx->setTextSize(2);
gfx->setTextColor(RGB565_WHITE);
gfx->setCursor(SAFE + 16, SAFE + 10);
gfx->println("Simple AV + Tilt Test");
gfx->drawRect(24, 76, 132, 64, RGB565_GREEN);
gfx->setCursor(44, 100);
gfx->println("RECORD");
gfx->drawRect(174, 76, 132, 64, RGB565_YELLOW);
gfx->setCursor(212, 100);
gfx->println("PLAY");
gfx->drawRect(324, 76, 132, 64, RGB565_CYAN);
gfx->setCursor(360, 100);
gfx->println("BEEP");
}
void drawDynamic() {
gfx->fillRect(20, 166, 440, 140, RGB565_BLACK);
gfx->setTextSize(2);
gfx->setTextColor(RGB565_WHITE);
gfx->setCursor(28, 178);
gfx->println(statusLine);
gfx->setTextColor(RGB565_CYAN);
gfx->setCursor(28, 212);
gfx->println(tiltLine);
gfx->setTextColor(RGB565_ORANGE);
gfx->setCursor(28, 246);
gfx->println(audioLine);
gfx->drawRect(28, 278, 300, 20, RGB565_DARKGREY);
}
void updateTilt() {
if (!qmi.getDataReady()) return;
if (!qmi.getAccelerometer(acc.x, acc.y, acc.z)) return;
String next = "Tilt: FLAT";
if (acc.x > 0.8f) next = "Tilt: RIGHT";
else if (acc.x < -0.8f) next = "Tilt: LEFT";
else if (acc.y > 0.8f) next = "Tilt: UP";
else if (acc.y < -0.8f) next = "Tilt: DOWN";
if (next != tiltLine) {
tiltLine = next;
drawDynamic();
}
}
void playBeep() {
static int16_t tone[16000];
for (int i = 0; i < 8000; ++i) {
float s = sinf(2.0f * 3.1415926f * 440.0f * ((float)i / SAMPLE_RATE));
int16_t v = (int16_t)(s * 6000.0f);
tone[i * 2] = v;
tone[i * 2 + 1] = v;
}
statusLine = "Playing test beep...";
drawDynamic();
i2s.write((uint8_t *)tone, 8000 * 2 * sizeof(int16_t));
statusLine = "Beep done";
drawDynamic();
}
void recordAudio() {
if (!audioBuf || !micReady) {
statusLine = "No audio buffer";
drawDynamic();
return;
}
statusLine = "Recording 2 sec...";
drawDynamic();
size_t total = 0;
uint16_t peakAll = 0;
while (total < AUDIO_BYTES) {
size_t chunk = min((size_t)4096, AUDIO_BYTES - total);
size_t got = i2s.readBytes((char *)(audioBuf + total), chunk);
if (!got) break;
total += got;
int16_t *s = (int16_t *)(audioBuf + total - got);
size_t n = got / sizeof(int16_t);
uint16_t peak = 0;
for (size_t i = 0; i < n; i++) {
uint16_t a = (uint16_t)abs((int)s[i]);
if (a > peak) peak = a;
}
if (peak > peakAll) peakAll = peak;
int bar = map((int)peak, 0, 20000, 0, 296);
if (bar < 0) bar = 0;
if (bar > 296) bar = 296;
gfx->fillRect(30, 280, 296, 16, RGB565_BLACK);
uint16_t c = (peak > 12000) ? RGB565_RED : ((peak > 6000) ? RGB565_YELLOW : RGB565_GREEN);
gfx->fillRect(30, 280, bar, 16, c);
}
audioLen = total;
audioLine = (total > 0) ? "Audio: recorded" : "Audio: empty";
if (total > 0) {
statusLine = String("Record done, peak=") + String((int)peakAll);
} else {
statusLine = "Record failed";
}
drawDynamic();
}
void playAudio() {
if (!audioLen) {
statusLine = "No recording. Try BEEP.";
drawDynamic();
return;
}
statusLine = "Playing recording...";
drawDynamic();
size_t wrote = i2s.write(audioBuf, audioLen);
statusLine = (wrote > 0) ? "Play done" : "Play failed";
drawDynamic();
}
void handleTouch() {
if (!touchIrq) return;
touchIrq = false;
int16_t x[1];
int16_t y[1];
uint8_t touched = touch.getPoint(x, y, 1);
if (!touched) return;
int px = x[0];
int py = y[0];
if (py < 76 || py > 140) return;
if (px >= 24 && px <= 156) recordAudio();
else if (px >= 174 && px <= 306) playAudio();
else if (px >= 324 && px <= 456) playBeep();
}
void setup() {
Serial.begin(115200);
Wire.begin(IIC_SDA, IIC_SCL);
pinMode(PA, OUTPUT);
digitalWrite(PA, HIGH);
if (!gfx->begin()) Serial.println("gfx begin failed");
bus->writeC8D8(0x36, 0xA0);
gfx->setBrightness(200);
pinMode(TP_RST, OUTPUT);
digitalWrite(TP_RST, LOW);
delay(30);
digitalWrite(TP_RST, HIGH);
delay(50);
touch.setPins(TP_RST, TP_INT);
touch.begin(Wire, 0x5A, IIC_SDA, IIC_SCL);
touch.setMaxCoordinates(480, 480);
touch.setSwapXY(true);
touch.setMirrorXY(true, false);
attachInterrupt(TP_INT, onTouchInt, FALLING);
if (qmi.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) {
qmi.configAccelerometer(SensorQMI8658::ACC_RANGE_4G, SensorQMI8658::ACC_ODR_1000Hz, SensorQMI8658::LPF_MODE_0);
qmi.enableAccelerometer();
} else {
tiltLine = "Tilt: IMU not found";
}
i2s.setPins(9, 45, 8, 10, 42);
if (!i2s.begin(I2S_MODE_STD, SAMPLE_RATE, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO, I2S_STD_SLOT_BOTH)) {
statusLine = "I2S init failed";
} else if (codecInit() != ESP_OK) {
statusLine = "ES8311 init failed";
} else if (micInit() != ESP_OK) {
statusLine = "ES7210 init failed";
} else {
micReady = true;
}
audioBuf = (uint8_t *)ps_malloc(AUDIO_BYTES);
if (!audioBuf) statusLine = "No PSRAM buffer";
drawStaticUi();
drawDynamic();
}
void loop() {
handleTouch();
if (millis() - lastTiltMs > 120) {
lastTiltMs = millis();
updateTilt();
}
delay(5);
}

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -1,8 +1,17 @@
TELEGRAM_BOT_TOKEN=replace_me TELEGRAM_BOT_TOKEN=replace_me
OPENAI_API_KEY=replace_me OPENAI_API_KEY=replace_me
ALLOWED_TELEGRAM_USERNAME=AidarKC ALLOWED_TELEGRAM_USERNAME=AidarKC
ALLOWED_TELEGRAM_PLAYERS=malvviiina:Милана,zodiaktechnika32:Сергей,oidasyda:Иван,blackbyrd1:Ворон,dimasol1:Дима
ALLOWED_TELEGRAM_CHANNEL_USERNAME=shine_writing
BOT_USERNAME=aidar_su_bot BOT_USERNAME=aidar_su_bot
OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe OPENAI_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe
TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS=300
OPENAI_TRANSCRIBE_TIMEOUT_SECONDS=900
OPENAI_TTS_MODEL=gpt-4o-mini-tts
OPENAI_TTS_VOICE=alloy
OPENAI_TTS_RESPONSE_FORMAT=opus
OPENAI_TTS_TIMEOUT_SECONDS=180
OPENAI_TTS_CHUNK_CHARS=3500
CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl CODEX_BIN=/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl
CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256 CODEX_WORKDIR=/home/ai/work/SHiNE/SHiNE-server-sha256
CODEX_TIMEOUT_SECONDS=900 CODEX_TIMEOUT_SECONDS=900

View File

@ -9,12 +9,41 @@
- История диалога хранится в JSONL-файле, путь передаётся в промпте. - История диалога хранится в JSONL-файле, путь передаётся в промпте.
- Сообщение может быть текстом или результатом распознавания голосового. - Сообщение может быть текстом или результатом распознавания голосового.
- Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение. - Ответ пойдёт пользователю в Telegram как обычное текстовое сообщение.
- Единственная рабочая реализация сервиса — Python-скрипт `py_bot_service.py`; старая Java-реализация удалена как нерабочая и не должна восстанавливаться без отдельного решения Айдара.
- В репозитории также есть отдельный Solana/Anchor-модуль `shine-solana/shine/`; он логически связан с SHiNE, но не должен автоматически подключаться к основному серверному deploy без отдельной команды.
- Перед изменениями внутри `shine-solana/shine/` читать локальные инструкции `shine-solana/shine/AGENTS.md`; в git не добавлять локальные ключи, `.git`, `.idea`, `.gradle`, `target`, `node_modules`, `test-ledger`, логи, временные run-отчёты и `.env`-конфиги.
## Авторитет команд и история
- Основной пользователь и источник команд — Айдар: `@AidarKC` / `@aidarkc`.
- Дополнительно разрешены игроки из whitelist (`ALLOWED_TELEGRAM_PLAYERS`), каждый со своей отдельной историей и рабочей папкой `Players/<username>/`.
- Игроки работают в режиме вопросов/анализа/подготовки материалов: в промпте явно задано правило не менять код проекта и писать материалы только в своей папке.
- Для неизвестных пользователей в личном чате сервис отвечает вежливым отказом.
- В Telegram-канале/группе `@shine_writing` сервис выполняет сообщения только от Айдара, а ответы отправляет в тот же чат.
- Если Telegram сообщает о миграции обычной группы в supergroup, сервис должен запомнить новый `chat_id` и отправлять ответы уже туда.
- На события подключения/отключения пользователей (join/leave) сервис не отвечает и ничего не отправляет.
## Очередь и состояние ## Очередь и состояние
- Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте. - Входящие задачи записываются в файловую очередь и обрабатываются строго по одной, чтобы не смешивать изменения в проекте.
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния. - Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
- Истории диалогов хранятся в JSONL; после команды `/new` старая история архивируется, а новая начинается отдельно. - Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
- Архив истории после `/new`: `data/history/<username>/archive/`.
- Для просмотра истории игрока открывать файлы в его папке истории по username.
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно. - Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
- После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать.
- Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`.
- Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`, `/voice_status`.
- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS. Промежуточные статусы и публичный отчёт в `@shine_writing` не озвучивать.
## Планы и отложенные фичи
- Планы проекта по отложенным фичам хранятся в `Dev_Docs/Future_Features/`.
- Внутри есть три горизонта:
- `near/` - ближайшие планы, обычно сегодня/завтра;
- `medium/` - среднесрочные планы, обычно недели или 1-2 месяца;
- `far/` - дальнее будущее без понятного срока.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам.
- Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя.
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
## Локальный запуск и systemd ## Локальный запуск и systemd
- Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`. - Основной запуск сервиса выполняется Python-скриптом `py_bot_service.py` из папки `SHiNE-agent-bot-coder/`.
@ -22,6 +51,7 @@
- Для проверки Codex без Telegram можно использовать self-test режим сервиса. - Для проверки Codex без Telegram можно использовать self-test режим сервиса.
- Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`. - Для постоянного локального запуска используется user-level systemd service `shine-agent-bot-coder`; скрипты установки лежат в `SHiNE-agent-bot-coder/scripts/systemd/`.
- Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service. - Если меняется логика сервиса, после изменений нужно проверить запуск локально и при необходимости перезапустить user systemd service.
- Команда Telegram `/restart_service` (и алиас `/restart`) доступна только Айдару.
## Правила ответа ## Правила ответа
- Пиши содержательно и коротко. - Пиши содержательно и коротко.

View File

@ -0,0 +1,25 @@
# AGENTS
## Назначение
- Это автоматически читаемые инструкции Codex для папки `SHiNE-agent-bot-coder/`.
- `SHiNE-agent-bot-coder` — локальный Telegram-бот-сервис агента-кодера для работы с проектом SHiNE.
- Если пользователь говорит «агент MD», «агент с MD» или похожим образом про файл инструкций Codex, считать, что имеется в виду `AGENTS.md`.
## Связанные инструкции
- Подробные служебные правила Telegram-обработчика лежат в `AGENT.md`.
- `AGENT.md` используется самим сервисом как файл инструкций, который передаётся в промпт обработчика входящих Telegram-сообщений.
- При изменении логики сервиса сначала читать `AGENT.md`, затем код `py_bot_service.py`.
## Планы и задачи
- Отложенные задачи проекта лежат в `../Dev_Docs/Future_Features/`.
- Точка входа по планам: `../Dev_Docs/Future_Features/README.md`.
- Горизонты планов:
- `near/` - ближайшие планы;
- `medium/` - среднесрочные планы;
- `far/` - дальнее будущее.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, кратко перечислять задачи по этим горизонтам.
- Не начинать реализацию задач из `Future_Features` без явной команды пользователя.
## Проверка после изменений
- Если меняется логика Telegram-бота, проверить локальный запуск или self-test, когда это уместно.
- Если меняется только документация или инструкции, достаточно проверить, что ссылки на документы актуальны.

View File

@ -0,0 +1,2 @@
@AGENTS.md
@AGENT.md

View File

@ -0,0 +1,26 @@
# Промпты для режима игроков (на согласование)
## 1) Базовый служебный промпт (добавка к задаче игрока)
```text
Режим игрока (обязательно):
- Пользователь: <Имя> (@<username>).
- Рабочая папка игрока: <project>/Players/<username>
- Код проекта не изменять.
- Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ.
- Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока.
```
## 2) Приветственное сообщение игроку (один раз)
```text
Привет, <Имя>.
Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ.
Команда /new начинает новую сессию и архивирует текущую историю.
```
## 3) Отказ неизвестному пользователю
```text
Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару.
```

View File

@ -2,27 +2,43 @@
Локальный Telegram-бот-сервис для пользователя `ai`: Локальный Telegram-бот-сервис для пользователя `ai`:
- принимает сообщения от `@AidarKC`; - принимает сообщения от `@AidarKC`;
- поддерживает whitelist игроков (`ALLOWED_TELEGRAM_PLAYERS`) с отдельными историями;
- ведёт историю диалога в `JSONL`; - ведёт историю диалога в `JSONL`;
- ставит задачи в файловую очередь; - ставит задачи в файловую очередь;
- обрабатывает задачи строго последовательно; - обрабатывает задачи строго последовательно;
- поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription); - поддерживает текстовые и голосовые сообщения (voice/audio через OpenAI transcription);
- вызывает Codex CLI и отправляет ответ в Telegram; - вызывает Codex CLI и отправляет ответ в Telegram;
- при рестарте восстанавливает незавершённые задачи. - умеет персонально для каждого пользователя озвучивать финальный ответ через OpenAI TTS;
- при рестарте восстанавливает незавершённые задачи;
- отправляет аварийный статус только если Codex молчит 2 минуты подряд во время активной задачи;
- принимает сообщения из канала/группы `@shine_writing`, выполняет команды только от `@AidarKC`;
- учитывает миграцию обычной Telegram-группы в supergroup и перенаправляет ответы на новый `chat_id`.
Рабочая реализация сервиса — только `py_bot_service.py`. Старая Java-реализация удалена, потому что не заработала и больше не используется.
## Структура ## Структура
- `.env` — локальные секреты и параметры запуска (не коммитится); - `.env` — локальные секреты и параметры запуска (не коммитится);
- `data/queue.jsonl` — очередь задач;
- `data/state.json` — текущее состояние (active job + текущий history-файл);
- `data/py_queue.jsonl` — очередь Python-сервиса; - `data/py_queue.jsonl` — очередь Python-сервиса;
- `data/py_state.json` — текущее состояние Python-сервиса; - `data/py_state.json` — текущее состояние Python-сервиса;
- `data/py_processed_updates.log` — дедуп входящих update; - `data/py_processed_updates.log` — дедуп входящих update;
- `data/history/*.jsonl` — активные истории; - `data/history/<username>/*.jsonl` — активные истории по пользователям;
- `data/history/archive/*.jsonl` — архив историй после `/new`. - `data/history/<username>/archive/*.jsonl` — архивы после `/new`.
## Локальный запуск ## Локальный запуск
1. Скопировать пример: 1. Скопировать пример:
- `cp .env.example .env` - `cp .env.example .env`
2. Заполнить секреты в `.env`. 2. Заполнить секреты в `.env`.
- `TELEGRAM_BOT_TOKEN` — токен рабочего Telegram-бота.
- `ALLOWED_TELEGRAM_USERNAME` — пользователь, чьи сообщения выполняются как команды.
- `ALLOWED_TELEGRAM_PLAYERS` — whitelist игроков в формате `username:Имя,username2:Имя2`.
- `ALLOWED_TELEGRAM_CHANNEL_USERNAME` — канал, из которого принимаются `channel_post`; обычные group/supergroup-сообщения обрабатываются как `message`.
- `TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS` — тайм-аут скачивания voice/audio из Telegram, по умолчанию 300 секунд.
- `OPENAI_TRANSCRIBE_TIMEOUT_SECONDS` — тайм-аут распознавания voice/audio в OpenAI, по умолчанию 900 секунд.
- `OPENAI_TTS_MODEL` — модель синтеза речи, по умолчанию `gpt-4o-mini-tts`.
- `OPENAI_TTS_VOICE` — голос синтеза речи, по умолчанию `alloy`.
- `OPENAI_TTS_RESPONSE_FORMAT` — аудиоформат для Telegram voice, по умолчанию `opus`.
- `OPENAI_TTS_TIMEOUT_SECONDS` — тайм-аут генерации одного фрагмента речи, по умолчанию 180 секунд.
- `OPENAI_TTS_CHUNK_CHARS` — максимальный размер одного фрагмента озвучки, по умолчанию 3500 символов.
3. Запуск: 3. Запуск:
- `python3 SHiNE-agent-bot-coder/py_bot_service.py` - `python3 SHiNE-agent-bot-coder/py_bot_service.py`
@ -42,3 +58,17 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
Проверка: Проверка:
- `systemctl --user status shine-agent-bot-coder --no-pager` - `systemctl --user status shine-agent-bot-coder --no-pager`
- `journalctl --user -u shine-agent-bot-coder -f` - `journalctl --user -u shine-agent-bot-coder -f`
Перезапуск после изменений:
- `systemctl --user restart shine-agent-bot-coder`
## Telegram-команды
- `/status` — активная задача и размер очереди.
- `/queue` — список задач в очереди.
- `/stop` — остановить текущую задачу.
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
- `/new` — архивировать текущую историю и начать новый диалог.
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
- `/voice_status` — показать состояние озвучивания для текущего пользователя.
- `/restart_service` — перезапустить сервис (только для Айдара); systemd должен поднять процесс заново.

View File

@ -1,50 +0,0 @@
plugins {
id 'java'
id 'application'
id 'com.github.johnrengelman.shadow' version '8.1.1'
}
group = 'shine.agent'
version = '1.0.0'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.telegram:telegrambots:6.9.7.1'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
implementation 'org.slf4j:slf4j-api:2.0.16'
runtimeOnly 'org.slf4j:slf4j-simple:2.0.16'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'org.apache.httpcomponents:httpcore:4.4.16'
implementation 'commons-codec:commons-codec:1.17.0'
testImplementation platform('org.junit:junit-bom:5.10.2')
testImplementation 'org.junit.jupiter:junit-jupiter'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
application {
mainClass = 'shine.agent.botcoder.BotCoderApplication'
}
tasks.named('jar') {
enabled = false
}
shadowJar {
archiveBaseName.set('shine-agent-bot-coder')
archiveClassifier.set('')
archiveVersion.set('')
mergeServiceFiles()
}
tasks.named('test') {
useJUnitPlatform()
}

File diff suppressed because it is too large Load Diff

View File

@ -1,76 +0,0 @@
package shine.agent.botcoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.telegram.telegrambots.meta.TelegramBotsApi;
import org.telegram.telegrambots.updatesreceivers.DefaultBotSession;
import shine.agent.botcoder.codex.CodexClient;
import shine.agent.botcoder.config.AppConfig;
import shine.agent.botcoder.history.HistoryManager;
import shine.agent.botcoder.openai.OpenAiTranscriber;
import shine.agent.botcoder.queue.QueueStore;
import shine.agent.botcoder.state.RuntimeStateStore;
import shine.agent.botcoder.state.SingleInstanceLock;
import shine.agent.botcoder.telegram.ProcessedUpdatesStore;
import shine.agent.botcoder.telegram.ShineAgentBot;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class BotCoderApplication {
private static final Logger log = LoggerFactory.getLogger(BotCoderApplication.class);
public static void main(String[] args) throws Exception {
Path serviceRoot = Path.of("").toAbsolutePath().normalize();
AppConfig config = AppConfig.load(serviceRoot);
Files.createDirectories(config.dataDir());
SingleInstanceLock appLock = SingleInstanceLock.tryAcquire(config.dataDir().resolve("app.lock"));
if (appLock == null) {
log.error("SHiNE-agent-bot-coder уже запущен: lock занят {}", config.dataDir().resolve("app.lock"));
return;
}
RuntimeStateStore stateStore = new RuntimeStateStore(config.dataDir().resolve("state.json"));
QueueStore queueStore = new QueueStore(config.dataDir().resolve("queue.jsonl"), stateStore);
HistoryManager historyManager = new HistoryManager(
config.dataDir().resolve("history"),
config.dataDir().resolve("history").resolve("archive"),
stateStore
);
List<String> recovered = queueStore.recoverActiveJobs();
if (!recovered.isEmpty()) {
historyManager.appendSystemEvent("active_jobs_recovered", java.util.Map.of("jobIds", recovered));
}
OpenAiTranscriber transcriber = new OpenAiTranscriber(config.openAiApiKey(), config.openAiTranscribeModel());
CodexClient codexClient = new CodexClient(config.codexBin(), config.codexWorkDir(), config.codexTimeoutSeconds());
ProcessedUpdatesStore processedUpdatesStore = new ProcessedUpdatesStore(
config.dataDir().resolve("processed_updates.log"),
5000
);
ShineAgentBot bot = new ShineAgentBot(config, queueStore, historyManager, transcriber, codexClient, processedUpdatesStore);
bot.startWorkers();
TelegramBotsApi botsApi = new TelegramBotsApi(DefaultBotSession.class);
botsApi.registerBot(bot);
log.info("SHiNE-agent-bot-coder запущен. allowed user: @{}", config.allowedTelegramUsername());
CountDownLatch latch = new CountDownLatch(1);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
bot.shutdown();
try {
appLock.close();
} catch (Exception e) {
log.warn("Не удалось закрыть lock-файл", e);
}
latch.countDown();
}, "shine-agent-bot-shutdown"));
latch.await();
}
}

View File

@ -1,225 +0,0 @@
package shine.agent.botcoder.codex;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CodexClient {
private static final Logger log = LoggerFactory.getLogger(CodexClient.class);
private final Path codexBin;
private final Path codexWorkDir;
private final int timeoutSeconds;
private final AtomicReference<Process> activeProcess = new AtomicReference<>();
public CodexClient(Path codexBin, Path codexWorkDir, int timeoutSeconds) {
this.codexBin = codexBin;
this.codexWorkDir = codexWorkDir;
this.timeoutSeconds = timeoutSeconds;
}
public String executePrompt(String prompt, CodexStatusListener statusListener) throws IOException, InterruptedException {
Path lastMessageFile = Files.createTempFile("shine-codex-last-message-", ".txt");
List<String> command = new ArrayList<>();
command.add(codexBin.toString());
command.add("exec");
command.add("--dangerously-bypass-approvals-and-sandbox");
command.add("--json");
command.add("-C");
command.add(codexWorkDir.toString());
command.add("-o");
command.add(lastMessageFile.toString());
command.add(prompt);
log.info("Запуск codex exec, bin={}, workdir={}", codexBin, codexWorkDir);
ProcessBuilder builder = new ProcessBuilder(command);
builder.redirectErrorStream(true);
Process process = builder.start();
activeProcess.set(process);
if (statusListener != null) {
statusListener.onStatus("Codex запущен");
}
StringBuilder output = new StringBuilder();
Thread outputThread = new Thread(() -> readOutput(process, output, statusListener));
outputThread.setDaemon(true);
outputThread.start();
boolean finished;
try {
finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
} catch (InterruptedException interrupted) {
process.destroyForcibly();
joinOutputThread(outputThread);
activeProcess.compareAndSet(process, null);
Thread.currentThread().interrupt();
throw interrupted;
}
try {
if (!finished) {
process.destroyForcibly();
joinOutputThread(outputThread);
log.error("Codex timeout after {}s", timeoutSeconds);
throw new IOException("Codex timeout after " + timeoutSeconds + "s");
}
joinOutputThread(outputThread);
int exitCode = process.exitValue();
String lastMessage = "";
if (Files.exists(lastMessageFile)) {
lastMessage = Files.readString(lastMessageFile, StandardCharsets.UTF_8).trim();
}
if (exitCode != 0) {
log.error("Codex exit code={}, outputTail={}", exitCode, tail(output.toString(), 500));
throw new IOException("Codex exited with code " + exitCode + ". Output: " + tail(output.toString(), 1800));
}
if (!lastMessage.isBlank()) {
return lastMessage;
}
String fallback = extractFallbackMessage(output.toString());
if (fallback.isBlank()) {
throw new IOException("Codex returned empty response");
}
return fallback;
} finally {
activeProcess.compareAndSet(process, null);
try {
Files.deleteIfExists(lastMessageFile);
} catch (IOException ignored) {
}
}
}
public boolean stopActiveProcess() {
Process process = activeProcess.getAndSet(null);
if (process == null) {
return false;
}
process.destroy();
try {
if (!process.waitFor(2, TimeUnit.SECONDS)) {
process.destroyForcibly();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
process.destroyForcibly();
}
return true;
}
private void readOutput(Process process, StringBuilder output, CodexStatusListener statusListener) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append('\n');
String status = normalizeStatusLine(line);
if (status != null && statusListener != null) {
statusListener.onStatus(status);
}
}
} catch (Exception ignored) {
}
}
private String normalizeStatusLine(String line) {
String trimmed = line == null ? "" : line.trim();
if (trimmed.isEmpty()) {
return null;
}
if (trimmed.contains("\"type\":\"thread.started\"")) {
return "Codex: инициализировал сессию";
}
if (trimmed.contains("\"type\":\"turn.started\"")) {
return "Codex: начал обработку запроса";
}
if (trimmed.contains("\"type\":\"item.completed\"") && trimmed.contains("\"type\":\"agent_message\"")) {
return "Codex: формирует финальный ответ";
}
if (trimmed.contains("\"type\":\"turn.completed\"")) {
return "Codex: завершил шаг";
}
if (trimmed.contains("\"type\":\"error\"")) {
return "Codex: ошибка выполнения";
}
if (trimmed.contains("\"type\":\"reasoning\"")) {
return "Codex: анализирует задачу";
}
if (trimmed.contains("\"type\":\"function_call\"")) {
return "Codex: вызывает инструмент";
}
if (trimmed.contains("\"type\":\"function_call_output\"")) {
return "Codex: получил результат инструмента";
}
if (trimmed.contains("\"type\":\"message\"") && trimmed.contains("\"role\":\"assistant\"")) {
return "Codex: формирует ответ";
}
if (trimmed.startsWith("mcp")) {
return "Codex: инициализирует MCP";
}
if (trimmed.startsWith("tokens used")) {
return "Codex: завершает обработку";
}
if (trimmed.startsWith("ERROR:")) {
return "Codex: ошибка выполнения";
}
return null;
}
private void joinOutputThread(Thread outputThread) throws InterruptedException {
try {
outputThread.join(Duration.ofSeconds(2).toMillis());
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw interrupted;
}
}
private String extractFallbackMessage(String rawOutput) {
String[] lines = rawOutput.split("\\R");
for (int i = lines.length - 1; i >= 0; i--) {
String line = lines[i].trim();
if (line.isEmpty()) {
continue;
}
if (line.startsWith("tokens used")) {
continue;
}
if (line.startsWith("OpenAI Codex")) {
continue;
}
if (line.startsWith("workdir:") || line.startsWith("model:") || line.startsWith("provider:")) {
continue;
}
if (line.startsWith("approval:") || line.startsWith("sandbox:") || line.startsWith("reasoning")) {
continue;
}
if (line.equals("user") || line.equals("exec") || line.equals("--------")) {
continue;
}
return line;
}
return "";
}
private String tail(String value, int maxLen) {
if (value.length() <= maxLen) {
return value;
}
return value.substring(value.length() - maxLen);
}
}

View File

@ -1,5 +0,0 @@
package shine.agent.botcoder.codex;
public interface CodexStatusListener {
void onStatus(String message);
}

View File

@ -1,88 +0,0 @@
package shine.agent.botcoder.config;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Map;
public record AppConfig(
String telegramBotToken,
String botUsername,
String allowedTelegramUsername,
String openAiApiKey,
String openAiTranscribeModel,
Path codexBin,
Path codexWorkDir,
int codexTimeoutSeconds,
int maxRetries,
Path dataDir,
Path agentInstructionsFile
) {
public static AppConfig load(Path serviceRoot) throws IOException {
Map<String, String> env = EnvLoader.load(serviceRoot.resolve(".env"));
String telegramBotToken = required(env, "TELEGRAM_BOT_TOKEN");
String openAiApiKey = required(env, "OPENAI_API_KEY");
String botUsername = env.getOrDefault("BOT_USERNAME", "aidar_su_bot");
String allowed = normalizeUsername(env.getOrDefault("ALLOWED_TELEGRAM_USERNAME", "AidarKC"));
String transcribeModel = env.getOrDefault("OPENAI_TRANSCRIBE_MODEL", "gpt-4o-mini-transcribe");
Path codexBin = Path.of(env.getOrDefault(
"CODEX_BIN",
"/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl"
));
Path codexWorkDir = Path.of(env.getOrDefault(
"CODEX_WORKDIR",
"/home/ai/work/SHiNE/SHiNE-server-sha256"
));
int timeout = parseInt(env.getOrDefault("CODEX_TIMEOUT_SECONDS", "900"), 900);
int retries = parseInt(env.getOrDefault("MAX_RETRIES", "3"), 3);
if (retries < 1) {
retries = 1;
}
Path dataDir = serviceRoot.resolve(env.getOrDefault("DATA_DIR", "./data")).normalize();
Path agentInstructions = serviceRoot.resolve("AGENT.md").normalize();
return new AppConfig(
telegramBotToken,
botUsername,
allowed,
openAiApiKey,
transcribeModel,
codexBin,
codexWorkDir,
timeout,
retries,
dataDir,
agentInstructions
);
}
public static String normalizeUsername(String value) {
if (value == null) {
return "";
}
String normalized = value.trim();
if (normalized.startsWith("@")) {
normalized = normalized.substring(1);
}
return normalized.toLowerCase();
}
private static String required(Map<String, String> env, String key) {
String value = env.get(key);
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Не задан обязательный параметр: " + key);
}
return value.trim();
}
private static int parseInt(String value, int fallback) {
try {
return Integer.parseInt(value.trim());
} catch (Exception ignored) {
return fallback;
}
}
}

View File

@ -1,45 +0,0 @@
package shine.agent.botcoder.config;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
public final class EnvLoader {
private EnvLoader() {
}
public static Map<String, String> load(Path envFile) throws IOException {
Map<String, String> values = new HashMap<>();
if (Files.exists(envFile)) {
for (String rawLine : Files.readAllLines(envFile)) {
String line = rawLine.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
int idx = line.indexOf('=');
if (idx <= 0) {
continue;
}
String key = line.substring(0, idx).trim();
String value = line.substring(idx + 1).trim();
values.put(key, stripQuotes(value));
}
}
System.getenv().forEach(values::put);
return values;
}
private static String stripQuotes(String value) {
if (value.length() >= 2) {
char first = value.charAt(0);
char last = value.charAt(value.length() - 1);
if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) {
return value.substring(1, value.length() - 1);
}
}
return value;
}
}

View File

@ -1,162 +0,0 @@
package shine.agent.botcoder.history;
import com.fasterxml.jackson.databind.ObjectMapper;
import shine.agent.botcoder.state.RuntimeState;
import shine.agent.botcoder.state.RuntimeStateStore;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class HistoryManager {
private static final DateTimeFormatter FILE_TS = DateTimeFormatter.ofPattern("yyyy-MM-dd_HHmmss");
private final Path historyDir;
private final Path archiveDir;
private final RuntimeStateStore stateStore;
private final ObjectMapper mapper;
public HistoryManager(Path historyDir, Path archiveDir, RuntimeStateStore stateStore) throws IOException {
this.historyDir = historyDir;
this.archiveDir = archiveDir;
this.stateStore = stateStore;
this.mapper = new ObjectMapper();
Files.createDirectories(historyDir);
Files.createDirectories(archiveDir);
ensureCurrentFile();
}
public synchronized Path currentHistoryFile() throws IOException {
return ensureCurrentFile();
}
public synchronized Path rotateHistory(String reason, String requestedBy) throws IOException {
Path current = ensureCurrentFile();
Path archived = archiveDir.resolve(current.getFileName().toString());
Files.move(current, archived, StandardCopyOption.REPLACE_EXISTING);
Path next = newHistoryFile();
appendSystemEvent(
"history_rotated",
Map.of(
"reason", reason,
"requestedBy", requestedBy,
"archivedFile", archived.toString()
)
);
return next;
}
public synchronized void appendIncomingText(long chatId, int messageId, String username, String text) throws IOException {
Map<String, Object> payload = basePayload("incoming_text");
payload.put("chatId", chatId);
payload.put("messageId", messageId);
payload.put("username", username);
payload.put("text", text);
append(payload);
}
public synchronized void appendIncomingVoice(long chatId, int messageId, String username, String fileId) throws IOException {
Map<String, Object> payload = basePayload("incoming_voice");
payload.put("chatId", chatId);
payload.put("messageId", messageId);
payload.put("username", username);
payload.put("telegramFileId", fileId);
append(payload);
}
public synchronized void appendTranscription(String jobId, String text) throws IOException {
Map<String, Object> payload = basePayload("voice_transcription");
payload.put("jobId", jobId);
payload.put("text", text);
append(payload);
}
public synchronized void appendCodexRequest(String jobId, String prompt) throws IOException {
Map<String, Object> payload = basePayload("codex_request");
payload.put("jobId", jobId);
payload.put("prompt", prompt);
append(payload);
}
public synchronized void appendCodexResponse(String jobId, String response) throws IOException {
Map<String, Object> payload = basePayload("codex_response");
payload.put("jobId", jobId);
payload.put("response", response);
append(payload);
}
public synchronized void appendOutgoingMessage(String jobId, long chatId, String text) throws IOException {
Map<String, Object> payload = basePayload("outgoing_message");
payload.put("jobId", jobId);
payload.put("chatId", chatId);
payload.put("text", text);
append(payload);
}
public synchronized void appendJobError(String jobId, String error, boolean willRetry, int attempts, int maxRetries) throws IOException {
Map<String, Object> payload = basePayload("job_error");
payload.put("jobId", jobId);
payload.put("error", error);
payload.put("willRetry", willRetry);
payload.put("attempts", attempts);
payload.put("maxRetries", maxRetries);
append(payload);
}
public synchronized void appendSystemEvent(String event, Map<String, Object> fields) throws IOException {
Map<String, Object> payload = basePayload(event);
payload.putAll(fields);
append(payload);
}
private Map<String, Object> basePayload(String type) {
Map<String, Object> payload = new HashMap<>();
payload.put("timestamp", Instant.now().toString());
payload.put("type", type);
return payload;
}
private void append(Map<String, Object> payload) throws IOException {
Path current = ensureCurrentFile();
Files.writeString(
current,
mapper.writeValueAsString(payload) + System.lineSeparator(),
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND
);
}
private Path ensureCurrentFile() throws IOException {
RuntimeState snapshot = stateStore.snapshot();
if (snapshot.currentHistoryFile != null && !snapshot.currentHistoryFile.isBlank()) {
Path configured = Path.of(snapshot.currentHistoryFile);
if (!configured.isAbsolute()) {
configured = historyDir.resolve(snapshot.currentHistoryFile).normalize();
}
Files.createDirectories(configured.getParent());
if (!Files.exists(configured)) {
Files.createFile(configured);
}
return configured;
}
return newHistoryFile();
}
private Path newHistoryFile() throws IOException {
String name = FILE_TS.format(LocalDateTime.now()) + "_" + UUID.randomUUID().toString().substring(0, 8) + ".jsonl";
Path file = historyDir.resolve(name);
Files.createFile(file);
stateStore.setCurrentHistoryFile(file.toString());
return file;
}
}

View File

@ -1,81 +0,0 @@
package shine.agent.botcoder.openai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.UUID;
public class OpenAiTranscriber {
private final HttpClient httpClient;
private final ObjectMapper mapper;
private final String apiKey;
private final String model;
public OpenAiTranscriber(String apiKey, String model) {
this.apiKey = apiKey;
this.model = model;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.build();
this.mapper = new ObjectMapper();
}
public String transcribe(byte[] audioBytes, String fileName) throws IOException, InterruptedException {
String boundary = "----shine-boundary-" + UUID.randomUUID();
byte[] body = buildMultipartBody(boundary, audioBytes, fileName);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.openai.com/v1/audio/transcriptions"))
.timeout(Duration.ofSeconds(120))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 300) {
throw new IOException("OpenAI transcription error HTTP " + response.statusCode() + ": " + response.body());
}
JsonNode root = mapper.readTree(response.body());
JsonNode text = root.get("text");
if (text == null || text.asText().isBlank()) {
throw new IOException("OpenAI transcription returned empty text");
}
return text.asText().trim();
}
private byte[] buildMultipartBody(String boundary, byte[] audioBytes, String fileName) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
String lineEnd = "\r\n";
String prefix = "--" + boundary + lineEnd;
out.write(prefix.getBytes(StandardCharsets.UTF_8));
out.write(("Content-Disposition: form-data; name=\"model\"" + lineEnd + lineEnd).getBytes(StandardCharsets.UTF_8));
out.write(model.getBytes(StandardCharsets.UTF_8));
out.write(lineEnd.getBytes(StandardCharsets.UTF_8));
out.write(prefix.getBytes(StandardCharsets.UTF_8));
out.write(("Content-Disposition: form-data; name=\"language\"" + lineEnd + lineEnd).getBytes(StandardCharsets.UTF_8));
out.write("ru".getBytes(StandardCharsets.UTF_8));
out.write(lineEnd.getBytes(StandardCharsets.UTF_8));
out.write(prefix.getBytes(StandardCharsets.UTF_8));
out.write(("Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"" + lineEnd).getBytes(StandardCharsets.UTF_8));
out.write(("Content-Type: audio/ogg" + lineEnd + lineEnd).getBytes(StandardCharsets.UTF_8));
out.write(audioBytes);
out.write(lineEnd.getBytes(StandardCharsets.UTF_8));
out.write(("--" + boundary + "--" + lineEnd).getBytes(StandardCharsets.UTF_8));
return out.toByteArray();
}
}

View File

@ -1,4 +0,0 @@
package shine.agent.botcoder.queue;
public record FailureResult(boolean willRetry, int attempts, int maxRetries) {
}

View File

@ -1,54 +0,0 @@
package shine.agent.botcoder.queue;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.time.Instant;
import java.util.UUID;
@JsonIgnoreProperties(ignoreUnknown = true)
public class QueueJob {
public String id;
public QueueStatus status;
public String type;
public long chatId;
public int messageId;
public String username;
public String text;
public String telegramFileId;
public String historyFile;
public String createdAt;
public String updatedAt;
public String activeSince;
public int attempts;
public String retryReason;
public String lastError;
public static QueueJob textJob(long chatId, int messageId, String username, String text, String historyFile) {
QueueJob job = baseJob(chatId, messageId, username, historyFile);
job.type = "text";
job.text = text;
return job;
}
public static QueueJob voiceJob(long chatId, int messageId, String username, String fileId, String historyFile) {
QueueJob job = baseJob(chatId, messageId, username, historyFile);
job.type = "voice";
job.telegramFileId = fileId;
return job;
}
private static QueueJob baseJob(long chatId, int messageId, String username, String historyFile) {
QueueJob job = new QueueJob();
String now = Instant.now().toString();
job.id = UUID.randomUUID().toString();
job.status = QueueStatus.PENDING;
job.chatId = chatId;
job.messageId = messageId;
job.username = username;
job.historyFile = historyFile;
job.createdAt = now;
job.updatedAt = now;
job.attempts = 0;
return job;
}
}

View File

@ -1,6 +0,0 @@
package shine.agent.botcoder.queue;
public enum QueueStatus {
PENDING,
ACTIVE
}

View File

@ -1,203 +0,0 @@
package shine.agent.botcoder.queue;
import com.fasterxml.jackson.databind.ObjectMapper;
import shine.agent.botcoder.state.RuntimeStateStore;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
public class QueueStore {
private final Path queueFile;
private final RuntimeStateStore stateStore;
private final ObjectMapper mapper;
private final List<QueueJob> jobs;
public QueueStore(Path queueFile, RuntimeStateStore stateStore) throws IOException {
this.queueFile = queueFile;
this.stateStore = stateStore;
this.mapper = new ObjectMapper();
Path parent = queueFile.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
this.jobs = loadQueue();
persistQueue();
}
public synchronized void enqueue(QueueJob job) throws IOException {
jobs.add(job);
persistQueue();
}
public synchronized List<String> recoverActiveJobs() throws IOException {
List<String> recovered = new ArrayList<>();
String now = Instant.now().toString();
for (QueueJob job : jobs) {
if (job.status == QueueStatus.ACTIVE) {
job.status = QueueStatus.PENDING;
job.retryReason = "service_restart_recovery";
job.updatedAt = now;
recovered.add(job.id);
}
}
stateStore.setActiveJobId(null);
persistQueue();
return recovered;
}
public synchronized Optional<QueueJob> activateNext() throws IOException {
for (QueueJob job : jobs) {
if (job.status == QueueStatus.PENDING) {
job.status = QueueStatus.ACTIVE;
job.activeSince = Instant.now().toString();
job.updatedAt = job.activeSince;
stateStore.setActiveJobId(job.id);
persistQueue();
return Optional.of(job);
}
}
return Optional.empty();
}
public synchronized void markDone(String jobId) throws IOException {
Iterator<QueueJob> iterator = jobs.iterator();
while (iterator.hasNext()) {
QueueJob job = iterator.next();
if (job.id.equals(jobId)) {
iterator.remove();
break;
}
}
stateStore.setActiveJobId(null);
persistQueue();
}
public synchronized Optional<QueueJob> getActiveJob() {
return jobs.stream().filter(j -> j.status == QueueStatus.ACTIVE).findFirst();
}
public synchronized int pendingCount() {
int count = 0;
for (QueueJob job : jobs) {
if (job.status == QueueStatus.PENDING) {
count++;
}
}
return count;
}
public synchronized int totalCount() {
return jobs.size();
}
public synchronized List<QueueJob> snapshot() {
return new ArrayList<>(jobs);
}
public synchronized boolean cancelActiveJob(String reason) throws IOException {
for (Iterator<QueueJob> iterator = jobs.iterator(); iterator.hasNext(); ) {
QueueJob job = iterator.next();
if (job.status == QueueStatus.ACTIVE) {
iterator.remove();
stateStore.setActiveJobId(null);
persistQueue();
return true;
}
}
return false;
}
public synchronized int cancelAll(String reason) throws IOException {
int size = jobs.size();
if (size == 0) {
return 0;
}
jobs.clear();
stateStore.setActiveJobId(null);
persistQueue();
return size;
}
public synchronized boolean cancelByIdPrefix(String idPrefix) throws IOException {
if (idPrefix == null || idPrefix.isBlank()) {
return false;
}
String normalized = idPrefix.trim().toLowerCase();
for (Iterator<QueueJob> iterator = jobs.iterator(); iterator.hasNext(); ) {
QueueJob job = iterator.next();
if (job.id != null && job.id.toLowerCase().startsWith(normalized)) {
if (job.status == QueueStatus.ACTIVE) {
stateStore.setActiveJobId(null);
}
iterator.remove();
persistQueue();
return true;
}
}
return false;
}
public synchronized FailureResult markFailed(String jobId, String error, int maxRetries) throws IOException {
for (Iterator<QueueJob> it = jobs.iterator(); it.hasNext(); ) {
QueueJob job = it.next();
if (!job.id.equals(jobId)) {
continue;
}
job.attempts = job.attempts + 1;
job.lastError = error;
job.updatedAt = Instant.now().toString();
stateStore.setActiveJobId(null);
if (job.attempts < maxRetries) {
job.status = QueueStatus.PENDING;
job.retryReason = error;
persistQueue();
return new FailureResult(true, job.attempts, maxRetries);
}
it.remove();
persistQueue();
return new FailureResult(false, job.attempts, maxRetries);
}
stateStore.setActiveJobId(null);
persistQueue();
return new FailureResult(false, maxRetries, maxRetries);
}
private List<QueueJob> loadQueue() throws IOException {
List<QueueJob> loaded = new ArrayList<>();
if (!Files.exists(queueFile)) {
return loaded;
}
for (String line : Files.readAllLines(queueFile, StandardCharsets.UTF_8)) {
String trimmed = line.trim();
if (trimmed.isEmpty()) {
continue;
}
loaded.add(mapper.readValue(trimmed, QueueJob.class));
}
return loaded;
}
private void persistQueue() throws IOException {
Files.writeString(queueFile, "", StandardCharsets.UTF_8);
for (QueueJob job : jobs) {
Files.writeString(
queueFile,
mapper.writeValueAsString(job) + System.lineSeparator(),
StandardCharsets.UTF_8,
StandardOpenOption.APPEND
);
}
}
}

View File

@ -1,10 +0,0 @@
package shine.agent.botcoder.state;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public class RuntimeState {
public String activeJobId;
public String currentHistoryFile;
public String updatedAt;
}

View File

@ -1,75 +0,0 @@
package shine.agent.botcoder.state;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
public class RuntimeStateStore {
private final Path stateFile;
private final ObjectMapper mapper;
private RuntimeState state;
public RuntimeStateStore(Path stateFile) throws IOException {
this.stateFile = stateFile;
this.mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
Path parent = stateFile.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
this.state = loadOrCreate();
persist();
}
public synchronized RuntimeState snapshot() {
RuntimeState copy = new RuntimeState();
copy.activeJobId = state.activeJobId;
copy.currentHistoryFile = state.currentHistoryFile;
copy.updatedAt = state.updatedAt;
return copy;
}
public synchronized void setActiveJobId(String activeJobId) throws IOException {
state.activeJobId = activeJobId;
state.updatedAt = Instant.now().toString();
persist();
}
public synchronized void setCurrentHistoryFile(String historyFile) throws IOException {
state.currentHistoryFile = historyFile;
state.updatedAt = Instant.now().toString();
persist();
}
private RuntimeState loadOrCreate() throws IOException {
if (!Files.exists(stateFile)) {
RuntimeState created = new RuntimeState();
created.updatedAt = Instant.now().toString();
return created;
}
String raw = Files.readString(stateFile, StandardCharsets.UTF_8).trim();
if (raw.isEmpty()) {
RuntimeState created = new RuntimeState();
created.updatedAt = Instant.now().toString();
return created;
}
RuntimeState loaded = mapper.readValue(raw, RuntimeState.class);
if (loaded.updatedAt == null) {
loaded.updatedAt = Instant.now().toString();
}
return loaded;
}
private void persist() throws IOException {
Files.writeString(
stateFile,
mapper.writeValueAsString(state),
StandardCharsets.UTF_8
);
}
}

View File

@ -1,50 +0,0 @@
package shine.agent.botcoder.state;
import java.io.Closeable;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public final class SingleInstanceLock implements Closeable {
private final FileChannel channel;
private final FileLock lock;
private final Path path;
private SingleInstanceLock(FileChannel channel, FileLock lock, Path path) {
this.channel = channel;
this.lock = lock;
this.path = path;
}
public static SingleInstanceLock tryAcquire(Path lockFile) throws IOException {
FileChannel channel = FileChannel.open(
lockFile,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE
);
FileLock lock = channel.tryLock();
if (lock == null) {
channel.close();
return null;
}
return new SingleInstanceLock(channel, lock, lockFile);
}
public Path path() {
return path;
}
@Override
public void close() throws IOException {
try {
if (lock != null && lock.isValid()) {
lock.release();
}
} finally {
channel.close();
}
}
}

View File

@ -1,75 +0,0 @@
package shine.agent.botcoder.telegram;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Iterator;
public class ProcessedUpdatesStore {
private final Path file;
private final LinkedHashSet<String> ids = new LinkedHashSet<>();
private final int maxEntries;
public ProcessedUpdatesStore(Path file, int maxEntries) throws IOException {
this.file = file;
this.maxEntries = Math.max(100, maxEntries);
Path parent = file.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
if (Files.exists(file)) {
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
for (String line : lines) {
String id = line.trim();
if (!id.isEmpty()) {
ids.add(id);
}
}
}
trimIfNeeded();
persistAll();
}
public synchronized boolean isDuplicateAndMark(String id) throws IOException {
if (id == null || id.isBlank()) {
return false;
}
String normalized = id.trim();
if (ids.contains(normalized)) {
return true;
}
ids.add(normalized);
trimIfNeeded();
Files.writeString(file, normalized + System.lineSeparator(), StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
return false;
}
private void trimIfNeeded() throws IOException {
if (ids.size() <= maxEntries) {
return;
}
int toRemove = ids.size() - maxEntries;
int removed = 0;
Iterator<String> it = ids.iterator();
while (it.hasNext() && removed < toRemove) {
it.next();
it.remove();
removed++;
}
persistAll();
}
private void persistAll() throws IOException {
StringBuilder sb = new StringBuilder();
for (String id : ids) {
sb.append(id).append(System.lineSeparator());
}
Files.writeString(file, sb.toString(), StandardCharsets.UTF_8);
}
}

View File

@ -1,589 +0,0 @@
package shine.agent.botcoder.telegram;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
import org.telegram.telegrambots.meta.api.methods.GetFile;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import shine.agent.botcoder.codex.CodexStatusListener;
import shine.agent.botcoder.codex.CodexClient;
import shine.agent.botcoder.config.AppConfig;
import shine.agent.botcoder.history.HistoryManager;
import shine.agent.botcoder.openai.OpenAiTranscriber;
import shine.agent.botcoder.queue.FailureResult;
import shine.agent.botcoder.queue.QueueJob;
import shine.agent.botcoder.queue.QueueStore;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
public class ShineAgentBot extends TelegramLongPollingBot {
private static final Logger log = LoggerFactory.getLogger(ShineAgentBot.class);
private final AppConfig config;
private final QueueStore queueStore;
private final HistoryManager historyManager;
private final OpenAiTranscriber transcriber;
private final CodexClient codexClient;
private final ExecutorService worker;
private final ExecutorService notifier;
private final AtomicBoolean running;
private final HttpClient httpClient;
private final ProcessedUpdatesStore processedUpdatesStore;
private final AtomicReference<QueueJob> activeJobRef = new AtomicReference<>();
private final AtomicLong activeJobStartedAt = new AtomicLong(0L);
private final ScheduledExecutorService heartbeatScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "shine-agent-heartbeat");
t.setDaemon(true);
return t;
});
public ShineAgentBot(
AppConfig config,
QueueStore queueStore,
HistoryManager historyManager,
OpenAiTranscriber transcriber,
CodexClient codexClient,
ProcessedUpdatesStore processedUpdatesStore
) {
this.config = config;
this.queueStore = queueStore;
this.historyManager = historyManager;
this.transcriber = transcriber;
this.codexClient = codexClient;
this.processedUpdatesStore = processedUpdatesStore;
this.worker = Executors.newSingleThreadExecutor(r -> {
Thread thread = new Thread(r, "shine-agent-bot-worker");
thread.setDaemon(true);
return thread;
});
this.notifier = Executors.newSingleThreadExecutor(r -> {
Thread thread = new Thread(r, "shine-agent-bot-notifier");
thread.setDaemon(true);
return thread;
});
this.running = new AtomicBoolean(true);
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.build();
}
public void startWorkers() {
worker.submit(this::processLoop);
}
public void shutdown() {
running.set(false);
codexClient.stopActiveProcess();
worker.shutdown();
notifier.shutdown();
heartbeatScheduler.shutdownNow();
try {
if (!worker.awaitTermination(10, TimeUnit.SECONDS)) {
worker.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
worker.shutdownNow();
}
try {
if (!notifier.awaitTermination(5, TimeUnit.SECONDS)) {
notifier.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
notifier.shutdownNow();
}
}
@Override
public String getBotUsername() {
return config.botUsername();
}
@Override
public String getBotToken() {
return config.telegramBotToken();
}
@Override
public void onUpdateReceived(Update update) {
if (update == null || !update.hasMessage()) {
return;
}
Message message = update.getMessage();
try {
String updateId = message.getChatId() + ":" + message.getMessageId();
if (processedUpdatesStore.isDuplicateAndMark(updateId)) {
log.info("Дубликат update пропущен: {}", updateId);
return;
}
} catch (IOException e) {
log.error("Не удалось проверить дубликат update", e);
}
User from = message.getFrom();
if (from == null) {
return;
}
String username = AppConfig.normalizeUsername(from.getUserName());
if (!username.equals(config.allowedTelegramUsername())) {
return;
}
try {
if (message.hasText() && "/new".equalsIgnoreCase(message.getText().trim())) {
handleNewCommand(message, username);
return;
}
if (message.hasText() && handleControlCommand(message)) {
return;
}
if (message.hasText() && !message.getText().isBlank()) {
enqueueText(message, username);
return;
}
if (message.hasVoice()) {
enqueueVoice(message, username);
return;
}
if (message.hasAudio()) {
enqueueAudio(message, username);
}
} catch (Exception e) {
log.error("Ошибка обработки update", e);
safeSendText(message.getChatId(), "Ошибка обработки входящего сообщения: " + shortError(e), message.getMessageId());
}
}
private void handleNewCommand(Message message, String username) throws IOException {
historyManager.appendSystemEvent(
"command_new_received",
Map.of(
"chatId", message.getChatId(),
"messageId", message.getMessageId(),
"username", username
)
);
var archived = historyManager.rotateHistory("command_new", username);
String response = "История очищена. Новый диалог начат.\nАрхив: " + archived.getFileName();
safeSendText(message.getChatId(), response, message.getMessageId());
}
private void enqueueText(Message message, String username) throws IOException {
QueueJob job = QueueJob.textJob(
message.getChatId(),
message.getMessageId(),
username,
message.getText(),
historyManager.currentHistoryFile().toString()
);
historyManager.appendIncomingText(message.getChatId(), message.getMessageId(), username, message.getText());
queueStore.enqueue(job);
safeSendText(message.getChatId(), "Принял в очередь: " + shortId(job.id), message.getMessageId());
}
private void enqueueVoice(Message message, String username) throws IOException {
String fileId = message.getVoice().getFileId();
QueueJob job = QueueJob.voiceJob(
message.getChatId(),
message.getMessageId(),
username,
fileId,
historyManager.currentHistoryFile().toString()
);
historyManager.appendIncomingVoice(message.getChatId(), message.getMessageId(), username, fileId);
queueStore.enqueue(job);
safeSendText(message.getChatId(), "Голосовое принято в очередь: " + shortId(job.id), message.getMessageId());
}
private void enqueueAudio(Message message, String username) throws IOException {
String fileId = message.getAudio().getFileId();
QueueJob job = QueueJob.voiceJob(
message.getChatId(),
message.getMessageId(),
username,
fileId,
historyManager.currentHistoryFile().toString()
);
historyManager.appendIncomingVoice(message.getChatId(), message.getMessageId(), username, fileId);
queueStore.enqueue(job);
safeSendText(message.getChatId(), "Аудио принято в очередь: " + shortId(job.id), message.getMessageId());
}
private void processLoop() {
while (running.get()) {
try {
Optional<QueueJob> next = queueStore.activateNext();
if (next.isEmpty()) {
Thread.sleep(500);
continue;
}
processJob(next.get());
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
} catch (Exception e) {
log.error("Ошибка worker loop", e);
try {
Thread.sleep(1000);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return;
}
}
}
}
private void processJob(QueueJob job) {
ScheduledFuture<?> heartbeat = null;
try {
log.info("Начало обработки jobId={}, type={}, chatId={}, attempts={}", job.id, job.type, job.chatId, job.attempts);
activeJobRef.set(job);
activeJobStartedAt.set(System.currentTimeMillis());
safeSendText(job.chatId, "Задача " + shortId(job.id) + " взята в работу.", job.messageId);
String userText = resolveUserText(job);
String prompt = buildPrompt(job, userText);
historyManager.appendCodexRequest(job.id, prompt);
log.info("Вызов Codex для jobId={}", job.id);
heartbeat = heartbeatScheduler.scheduleAtFixedRate(
() -> notifier.submit(() ->
safeSendText(job.chatId, "Статус " + shortId(job.id) + ": в работе " + elapsedSeconds() + "с", job.messageId)
),
30, 30, TimeUnit.SECONDS
);
String answer;
answer = codexClient.executePrompt(prompt, buildStatusListener(job));
log.info("Codex завершился для jobId={}, длина ответа={}", job.id, answer.length());
safeSendText(job.chatId, "Codex завершил обработку, отправляю результат.", job.messageId);
sendLongMessage(job.chatId, answer, job.messageId);
historyManager.appendCodexResponse(job.id, answer);
historyManager.appendOutgoingMessage(job.id, job.chatId, answer);
queueStore.markDone(job.id);
log.info("Задача завершена jobId={}", job.id);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
handleInterruptedJob(job, e);
} catch (Exception e) {
handleFailedJob(job, e);
} finally {
if (heartbeat != null) {
heartbeat.cancel(true);
}
activeJobRef.set(null);
activeJobStartedAt.set(0L);
}
}
private void handleInterruptedJob(QueueJob job, InterruptedException e) {
if (!running.get()) {
log.info("Обработка jobId={} прервана из-за остановки сервиса", job.id);
try {
historyManager.appendSystemEvent("job_interrupted_on_shutdown", Map.of(
"jobId", job.id,
"reason", shortError(e)
));
} catch (Exception ignored) {
}
return;
}
handleFailedJob(job, e);
}
private void handleFailedJob(QueueJob job, Exception e) {
String error = shortError(e);
log.error("Ошибка обработки jobId={}: {}", job.id, error, e);
try {
if (!isJobStillInQueue(job.id)) {
log.info("Задача {} уже удалена из очереди, ошибка не ретраится", job.id);
return;
}
FailureResult failure = queueStore.markFailed(job.id, error, config.maxRetries());
historyManager.appendJobError(job.id, error, failure.willRetry(), failure.attempts(), failure.maxRetries());
String message = failure.willRetry()
? "Ошибка выполнения задачи " + shortId(job.id) + ", повтор: " + failure.attempts() + "/" + failure.maxRetries()
: "Ошибка выполнения задачи " + shortId(job.id) + ". Лимит попыток исчерпан.";
safeSendText(job.chatId, message, job.messageId);
} catch (Exception inner) {
log.error("Не удалось зафиксировать ошибку задачи {}", job.id, inner);
}
}
private boolean isJobStillInQueue(String jobId) {
for (QueueJob item : queueStore.snapshot()) {
if (jobId.equals(item.id)) {
return true;
}
}
return false;
}
private CodexStatusListener buildStatusListener(QueueJob job) {
AtomicReference<String> lastStatus = new AtomicReference<>("");
AtomicLong lastSentAt = new AtomicLong(0L);
return status -> {
long now = System.currentTimeMillis();
String prev = lastStatus.get();
boolean changed = !status.equals(prev);
boolean heartbeatDue = now - lastSentAt.get() > 30_000;
if (changed || heartbeatDue) {
String text = changed
? "Статус " + shortId(job.id) + ": " + status
: "Статус " + shortId(job.id) + ": в работе " + elapsedSeconds() + "с";
notifier.submit(() -> safeSendText(job.chatId, text, job.messageId));
lastStatus.set(status);
lastSentAt.set(now);
}
};
}
private String resolveUserText(QueueJob job) throws IOException, InterruptedException, TelegramApiException {
if (!"voice".equals(job.type)) {
return job.text;
}
byte[] audio = downloadTelegramFile(job.telegramFileId);
String transcription = transcriber.transcribe(audio, job.id + ".ogg");
historyManager.appendTranscription(job.id, transcription);
safeSendText(job.chatId, "Распознано:\n" + transcription, job.messageId);
return transcription;
}
private byte[] downloadTelegramFile(String fileId) throws IOException, InterruptedException, TelegramApiException {
GetFile getFile = new GetFile(fileId);
org.telegram.telegrambots.meta.api.objects.File tgFile = execute(getFile);
String fileUrl = tgFile.getFileUrl(getBotToken());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(fileUrl))
.timeout(Duration.ofSeconds(60))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() >= 300) {
throw new IOException("Telegram file download failed HTTP " + response.statusCode());
}
return response.body();
}
private String buildPrompt(QueueJob job, String text) {
String retryBlock = "";
if (job.retryReason != null && !job.retryReason.isBlank()) {
retryBlock = "\n\ометка retry: " + job.retryReason;
}
return """
Пришло сообщение в Telegram.
Тип: %s
Username отправителя: @%s
Текст для обработки:
%s
История диалога (JSONL): %s
Инструкции агента: %s
Работай в рабочем проекте аккуратно и верни только текст ответа пользователю.%s
""".formatted(
job.type,
job.username,
text,
job.historyFile,
config.agentInstructionsFile(),
retryBlock
);
}
private boolean handleControlCommand(Message message) throws IOException {
String text = message.getText().trim();
String lower = text.toLowerCase();
if ("/start".equals(lower) || "/help".equals(lower)) {
safeSendText(message.getChatId(), helpText(), message.getMessageId());
return true;
}
if ("/status".equals(lower)) {
safeSendText(message.getChatId(), buildStatusText(), message.getMessageId());
return true;
}
if ("/queue".equals(lower)) {
safeSendText(message.getChatId(), buildQueueText(), message.getMessageId());
return true;
}
if ("/stop".equals(lower)) {
boolean stopped = codexClient.stopActiveProcess();
if (stopped) {
queueStore.cancelActiveJob("stopped_by_user");
historyManager.appendSystemEvent("job_stopped_by_user", Map.of(
"timestamp", Instant.now().toString()
));
safeSendText(message.getChatId(), "Текущая задача остановлена и удалена из очереди.", message.getMessageId());
} else {
safeSendText(message.getChatId(), "Сейчас нет активной задачи.", message.getMessageId());
}
return true;
}
if (lower.startsWith("/cancel")) {
String[] parts = text.split("\\s+", 2);
if (parts.length < 2) {
safeSendText(message.getChatId(), "Использование: /cancel <id|all>", message.getMessageId());
return true;
}
String arg = parts[1].trim();
if ("all".equalsIgnoreCase(arg)) {
codexClient.stopActiveProcess();
int cancelled = queueStore.cancelAll("cancel_all_by_user");
safeSendText(message.getChatId(), "Удалено задач из очереди: " + cancelled, message.getMessageId());
return true;
}
Optional<QueueJob> active = queueStore.getActiveJob();
if (active.isPresent() && active.get().id != null
&& active.get().id.toLowerCase().startsWith(arg.toLowerCase())) {
codexClient.stopActiveProcess();
}
boolean cancelled = queueStore.cancelByIdPrefix(arg);
safeSendText(message.getChatId(),
cancelled ? "Задача удалена: " + arg : "Задача не найдена: " + arg,
message.getMessageId());
return true;
}
return false;
}
private String buildStatusText() {
Optional<QueueJob> active = queueStore.getActiveJob();
int pending = queueStore.pendingCount();
if (active.isEmpty()) {
return "Статус: активной задачи нет.\nВ очереди pending: " + pending;
}
QueueJob job = active.get();
return "Статус: активная задача " + shortId(job.id) +
"\nТип: " + job.type +
"\опытка: " + (job.attempts + 1) + "/" + config.maxRetries() +
"\nВыполняется: " + elapsedSeconds() + "с" +
"\nPending: " + pending;
}
private String buildQueueText() {
List<QueueJob> jobs = queueStore.snapshot();
if (jobs.isEmpty()) {
return "Очередь пуста.";
}
StringBuilder sb = new StringBuilder();
sb.append("Очередь: ").append(jobs.size()).append('\n');
int limit = Math.min(jobs.size(), 10);
for (int i = 0; i < limit; i++) {
QueueJob job = jobs.get(i);
sb.append(i + 1).append(") ")
.append(shortId(job.id))
.append(" [").append(job.status).append("] ")
.append(job.type)
.append(" attempts=").append(job.attempts)
.append('\n');
}
if (jobs.size() > limit) {
sb.append("...и ещё ").append(jobs.size() - limit).append(" задач");
}
return sb.toString().trim();
}
private String helpText() {
return """
Доступные команды:
/status активная задача и размер очереди
/queue список задач в очереди
/stop остановить текущую задачу
/cancel <id|all> удалить задачу по id (префикс) или все
/new архивировать историю и начать новую
/help эта справка
""";
}
private void safeSendText(long chatId, String text, Integer replyToMessageId) {
try {
SendMessage message = new SendMessage();
message.setChatId(String.valueOf(chatId));
message.setText(trimForTelegram(text));
if (replyToMessageId != null) {
message.setReplyToMessageId(replyToMessageId);
}
execute(message);
} catch (Exception e) {
log.error("Не удалось отправить сообщение в Telegram", e);
}
}
private void sendLongMessage(long chatId, String text, Integer replyToMessageId) {
String normalized = text == null ? "" : text.strip();
if (normalized.isEmpty()) {
safeSendText(chatId, "(пустой ответ)", replyToMessageId);
return;
}
int max = 3500;
int start = 0;
while (start < normalized.length()) {
int end = Math.min(start + max, normalized.length());
String part = normalized.substring(start, end);
safeSendText(chatId, part, replyToMessageId);
start = end;
}
}
private String trimForTelegram(String value) {
if (value == null) {
return "";
}
String text = value.strip();
int max = 3900;
if (text.length() <= max) {
return text;
}
return text.substring(0, max) + "\n...[обрезано]";
}
private String shortId(String id) {
if (id == null || id.length() < 8) {
return id;
}
return id.substring(0, 8);
}
private long elapsedSeconds() {
long started = activeJobStartedAt.get();
if (started <= 0) {
return 0;
}
return (System.currentTimeMillis() - started) / 1000L;
}
private String shortError(Throwable e) {
String message = e.getMessage();
if (message == null || message.isBlank()) {
return e.getClass().getSimpleName();
}
String normalized = message.replace('\n', ' ').replace('\r', ' ').trim();
if (normalized.length() > 600) {
return normalized.substring(0, 600);
}
return normalized;
}
}

View File

@ -1,2 +1,2 @@
client.version=1.2.84 client.version=1.2.96
server.version=1.2.78 server.version=1.2.90

View File

@ -6,6 +6,7 @@ REMOTE_HOST="${REMOTE_HOST:-player@45.136.124.227}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}" REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/home/player/SHiNE/shine-ui}" EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/home/player/SHiNE/shine-ui}"
ALLOW_CADDY_MISMATCH="${ALLOW_CADDY_MISMATCH:-0}" ALLOW_CADDY_MISMATCH="${ALLOW_CADDY_MISMATCH:-0}"
EXPECTED_CADDY_SITE="${EXPECTED_CADDY_SITE:-shineup.me}"
BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)" BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)"
VERSION_FILE="VERSION.properties" VERSION_FILE="VERSION.properties"
export BUILD_VERSION export BUILD_VERSION
@ -59,9 +60,68 @@ CADDY_CONFIG_PATH="$(ssh "$REMOTE_HOST" "set -e; \
cfg=\$(printf '%s' \"\$exec_line\" | sed -n 's/.*--config \\([^ ]*\\).*/\\1/p' | head -n 1); \ cfg=\$(printf '%s' \"\$exec_line\" | sed -n 's/.*--config \\([^ ]*\\).*/\\1/p' | head -n 1); \
if [ -z \"\$cfg\" ]; then cfg=/etc/caddy/Caddyfile; fi; \ if [ -z \"\$cfg\" ]; then cfg=/etc/caddy/Caddyfile; fi; \
printf '%s' \"\$cfg\"")" printf '%s' \"\$cfg\"")"
CADDY_ROOT_LINE="$(ssh "$REMOTE_HOST" "sudo grep -n 'root \\* ' '$CADDY_CONFIG_PATH' | head -n 1 || true")" ROOT_CHECK_OUTPUT="$(ssh "$REMOTE_HOST" "set -euo pipefail; \
if [[ -n "$CADDY_ROOT_LINE" && "$CADDY_ROOT_LINE" != *"$EXPECTED_CADDY_UI_ROOT"* ]]; then cfg='$CADDY_CONFIG_PATH'; \
echo "ERROR: Caddy root mismatch. Found: $CADDY_ROOT_LINE" >&2 site='$EXPECTED_CADDY_SITE'; \
sudo awk -v site=\"\$site\" '
BEGIN { in_site=0; depth=0; root_line=\"\"; root_lineno=0; have_site=0; }
{
line=\$0;
trimmed=line;
sub(/^[[:space:]]+/, \"\", trimmed);
sub(/[[:space:]]+$/, \"\", trimmed);
if (!in_site) {
if (trimmed ~ \"^\" site \"[[:space:]]*\\\\{\") {
in_site=1;
have_site=1;
depth=1;
next;
}
} else {
if (trimmed ~ /^root[[:space:]]+\\*[[:space:]]+/ && root_line == \"\") {
root_line=line;
root_lineno=NR;
}
opens=gsub(/\\{/, \"{\", line);
closes=gsub(/\\}/, \"}\", line);
depth += (opens - closes);
if (depth <= 0) {
in_site=0;
}
}
}
END {
if (!have_site) {
print \"SITE_NOT_FOUND\";
exit 0;
}
if (root_line == \"\") {
print \"ROOT_NOT_FOUND\";
exit 0;
}
print root_lineno \":\" root_line;
}
' \"\$cfg\"")"
if [[ "$ROOT_CHECK_OUTPUT" == "SITE_NOT_FOUND" ]]; then
echo "ERROR: Caddy site block not found: $EXPECTED_CADDY_SITE" >&2
echo "Caddy config: $CADDY_CONFIG_PATH" >&2
if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then
echo "Set ALLOW_CADDY_MISMATCH=1 to bypass this check." >&2
exit 1
fi
echo "WARN: proceeding due to ALLOW_CADDY_MISMATCH=1"
elif [[ "$ROOT_CHECK_OUTPUT" == "ROOT_NOT_FOUND" ]]; then
echo "ERROR: root directive not found inside site block: $EXPECTED_CADDY_SITE" >&2
echo "Caddy config: $CADDY_CONFIG_PATH" >&2
if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then
echo "Set ALLOW_CADDY_MISMATCH=1 to bypass this check." >&2
exit 1
fi
echo "WARN: proceeding due to ALLOW_CADDY_MISMATCH=1"
elif [[ "$ROOT_CHECK_OUTPUT" != *"$EXPECTED_CADDY_UI_ROOT"* ]]; then
echo "ERROR: Caddy root mismatch for site $EXPECTED_CADDY_SITE. Found: $ROOT_CHECK_OUTPUT" >&2
echo "Caddy config: $CADDY_CONFIG_PATH" >&2 echo "Caddy config: $CADDY_CONFIG_PATH" >&2
echo "Expected path fragment: $EXPECTED_CADDY_UI_ROOT" >&2 echo "Expected path fragment: $EXPECTED_CADDY_UI_ROOT" >&2
if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then

View File

@ -1,36 +0,0 @@
краткая «памятка себе» по базовым классам и как они связаны.
server.logic.InboundMessageProcessor (устаревший путь)
Роль: маршрутизатор бинарного протокола: берёт входящие байты, читает первые 4 байта как op, находит MessageHandler и отдаёт ему сообщение.
Что возвращает: байтовый ответ хэндлера; при ошибках — 4 байта со статусом (BAD_REQUEST/INTERNAL_ERROR).
Важно: сейчас фактически не используется (карта HANDLERS пустая/закомментирована) — это «след» старого бинарного протокола, который вы заменили на JSON-WS.
server.ws.BlockchainTmpRecoveryOnStartup
Роль: «автослесарь» при старте: чинит последствия падения во время записи блокчейн-файла.
Логика: ищет *.tmp_bch в data/, сравнивает размеры tmp, main .bch и state.fileSizeBytes из БД.
Решения:
если stateSize == mainSize → tmp мусор, удаляем;
если stateSize == tmpSize → tmp актуален, атомарно заменяем main;
если не сходится / подозрительно (нет state, но есть main+tmp и т.п.) → CRITICAL + стоп сервера.
Итог: гарантирует, что на запуске не будет «тихо битого» блокчейна.
server.ws.BlockchainWsEndpoint
Роль: WS-эндпоинт Jetty, который принимает и бинарные, и текстовые сообщения.
Connect: сохраняет Session, кладёт её в ConnectionContext.
Binary: асинхронно вызывает InboundMessageProcessor.process(msg) и отправляет байтовый ответ. (Это тот самый устаревший путь.)
Text (JSON): асинхронно вызывает JsonInboundProcessor.processJson(message, connectionContext) и отправляет строку JSON.
Close: удаляет соединение из ActiveConnectionsRegistry, чистит ConnectionContext.
Смысл: один входной узел WS, где JSON — основной протокол, binary — “наследие”.
server.ws.WsServer
Роль: точка входа сервера.
Порядок запуска:
BlockchainTmpRecoveryOnStartup.runRecoveryOrThrow() — если не смог починить/сопоставить → сервер не стартует;
читает порт из AppConfig (server.port), иначе 7070;
поднимает Jetty, конфигурирует WS-контейнер, маппит /ws → BlockchainWsEndpoint, ставит idleTimeout.
Итог: «бутстрап»: сначала безопасность файлов, потом сеть.

View File

@ -1,286 +0,0 @@
JSON WebSocket протокол сервера: список существующих запросов (op)
Общий формат любого запроса (client → server):
op - имя операции (строка)
requestId - идентификатор запроса для связывания с ответом (строка)
payload - объект с параметрами конкретной операции (object)
Общий формат любого ответа (server → client):
op - имя операции (строка, совпадает с запросом)
requestId - идентификатор запроса (строка, совпадает с запросом)
status - статус результата (200 = успех, другое = ошибка)
payload - объект с полями ответа (object; при ошибке содержит code/message)
Группа: Авторизация и сессии
Эта группа управляет входом пользователя, созданием/обновлением сессий и безопасным завершением активных подключений.
----- AuthChallenge
Одноразовый шаг 1: по логину выдаёт nonce (authNonce), который затем подписывается на шаге 2.
Запрос:
op - "AuthChallenge"
requestId - id запроса
login - логин пользователя, для которого начинается авторизация
Ответ (успех):
op - "AuthChallenge"
requestId - id запроса
status - 200 если успех
authNonce - одноразовый nonce, Base64Url(32 bytes) без padding
Ответ (ошибка):
op - "AuthChallenge"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
----- CreateAuthSession
Шаг 2: проверяет подпись владения ключом, создаёт новую active_session и возвращает sessionId/sessionPwd.
Запрос:
op - "CreateAuthSession"
requestId - id запроса
storagePwd - ключ/пароль хранилища клиента, base64(32 bytes)
timeMs - время на клиенте в мс (для защиты от повторов и проверки рассинхрона)
signatureB64 - подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (base64)
clientInfo - короткая строка о клиенте/устройстве (до 50 символов), опционально
Ответ (успех):
op - "CreateAuthSession"
requestId - id запроса
status - 200 если успех
sessionId - идентификатор сессии, Base64Url(32 bytes)
sessionPwd - секрет сессии, Base64Url(32 bytes)
Ответ (ошибка):
op - "CreateAuthSession"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
----- RefreshSession
Повторный вход без подписи: проверяет sessionId+sessionPwd, обновляет метаданные сессии и возвращает storagePwd.
Запрос:
op - "RefreshSession"
requestId - id запроса
sessionId - идентификатор ранее выданной сессии (base64/url-safe строка)
sessionPwd - секрет ранее выданной сессии (base64/url-safe строка)
clientInfo - короткая строка о клиенте/устройстве (до 50 символов), опционально
Ответ (успех):
op - "RefreshSession"
requestId - id запроса
status - 200 если успех
storagePwd - пароль хранилища, сохранённый в сессии (base64(32 bytes))
Ответ (ошибка):
op - "RefreshSession"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
----- ListSessions
Возвращает список всех активных сессий текущего пользователя (для управления устройствами/входами).
Запрос:
op - "ListSessions"
requestId - id запроса
timeMs - время на клиенте в мс (нужно только если статус AUTH_IN_PROGRESS)
signatureB64 - подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (нужно только если статус AUTH_IN_PROGRESS)
Ответ (успех):
op - "ListSessions"
requestId - id запроса
status - 200 если успех
sessions - массив активных сессий пользователя
Поля элемента sessions[i]:
sessionId - идентификатор сессии, Base64Url(32 bytes)
clientInfoFromClient - что прислал клиент в clientInfo
clientInfoFromRequest - что собрал сервер из окружения (UA/платформа и т.п.)
geo - строка "Country, City" или "unknown"
lastAuthirificatedAtMs - время последней успешной авторизации/refresh (мс)
Ответ (ошибка):
op - "ListSessions"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
----- CloseActiveSession
Закрывает одну активную сессию пользователя (указанную или текущую), при необходимости подтверждая владение ключом подписью.
Запрос:
op - "CloseActiveSession"
requestId - id запроса
sessionId - идентификатор сессии для закрытия (если пусто, закрывается текущая)
timeMs - время на клиенте в мс (нужно только если статус AUTH_IN_PROGRESS)
signatureB64 - подпись Ed25519 над строкой "AUTHORIFICATED:" + timeMs + authNonce (нужно только если статус AUTH_IN_PROGRESS)
Ответ (успех):
op - "CloseActiveSession"
requestId - id запроса
status - 200 если успех
Ответ (ошибка):
op - "CloseActiveSession"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
Группа: Блокчейн (загрузка блоков)
Эта группа отвечает за приём и валидацию блоков (цепочка, линии, подпись/хэш) и атомарную запись в БД+файл.
----- AddBlock
Добавляет следующий блок в конкретный blockchainName, строго проверяя номера, prev-хэши, линии, подпись и лимит размера.
Запрос:
op - "AddBlock"
requestId - id запроса
blockchainName - имя цепочки (по нему вычисляется login)
globalNumber - глобальный номер добавляемого блока (должен быть serverLastGlobalNumber+1)
prevGlobalHash - HEX(64) предыдущего глобального хэша (или "" только там, где это допускается правилами)
blockBytesB64 - полный блок (raw+signature+hash) в Base64
Ответ (успех):
op - "AddBlock"
requestId - id запроса
status - 200 если успех
reasonCode - null при успехе
serverLastGlobalNumber - последний глобальный номер, который сервер считает актуальным после обработки
serverLastGlobalHash - последний глобальный хэш (HEX(64)) после обработки
Ответ (ошибка):
op - "AddBlock"
requestId - id запроса
status - код ошибки (например 400/404/413/500)
reasonCode - строка причины (например bad_block_base64, bad_global_number, limit_exceeded и т.п.)
serverLastGlobalNumber - текущий серверный lastGlobalNumber
serverLastGlobalHash - текущий серверный lastGlobalHash (HEX(64), если известен)
Группа: Параметры пользователя (UserParams)
Эта группа хранит и отдаёт подписанные клиентом параметры (ключ-значение) для синхронизации и состояния.
----- UpsertUserParam
Добавляет или обновляет параметр пользователя, проверяя подпись Ed25519 и применяя запись только если time_ms новее.
Запрос:
op - "UpsertUserParam"
requestId - id запроса
login - логин пользователя
param - имя параметра
time_ms - метка времени значения в мс
value - значение параметра
device_key - публичный ключ устройства, base64(32 bytes)
signature - подпись Ed25519 от строки USER_PARAMETER_PREFIX + login + param + time_ms + value
Ответ (успех):
op - "UpsertUserParam"
requestId - id запроса
status - 200 если успех
Ответ (ошибка):
op - "UpsertUserParam"
requestId - id запроса
status - код ошибки
code - строковый код ошибки
message - человекочитаемое описание ошибки
----- GetUserParam
Возвращает один сохранённый параметр пользователя.
Запрос:
op - "GetUserParam"
requestId - id запроса
login - логин пользователя
param - имя параметра
Ответ (успех):
op - "GetUserParam"
requestId - id запроса
status - 200
login
param
time_ms
value
device_key
signature
Ответ (не найдено):
op - "GetUserParam"
requestId
status - 404
Ответ (ошибка):
op
requestId
status
code
message
----- ListUserParams
Возвращает все сохранённые параметры пользователя.
Запрос:
op - "ListUserParams"
requestId
login
Ответ (успех):
op
requestId
status - 200
login
params - массив параметров
Поля params[i]:
login
param
time_ms
value
device_key
signature
Ответ (ошибка):
op
requestId
status
code
message
Группа: Тестовые/временные операции
Эта группа предназначена для отладки и первичного наполнения БД.
----- AddUser
Тестовая регистрация локального пользователя.
Запрос:
op - "AddUser"
requestId
login
blockchainName
solanaKey
deviceKey
bchLimit
Ответ (успех):
op
requestId
status - 200
Ответ (ошибка):
op
requestId
status
code
message

View File

@ -1,165 +0,0 @@
нет это неправильно откатись к1) Общий формат записи блока (BchBlockEntry)
Блок хранится как FULL = RAW + TAIL, где RAW участвует в хэшировании/подписи, а TAIL хранит подпись и итоговый хэш.
RAW (BigEndian)
recordSize [4] int32 — размер RAW в байтах (включая поля RAW-заголовка и bodyBytes), без signature64 и hash32.
recordNumber [4] int32 — глобальный порядковый номер блока (сквозной по всему блокчейну).
timestamp [8] int64 — Unix seconds (время создания блока).
lineIndex [2] int16 — номер линии (канонические линии см. LineIndex).
lineNumber [4] int32 — порядковый номер внутри выбранной линии.
bodyBytes [N] bytes — тело блока, начинается с [type][version] (и дальше подформат конкретного body).
TAIL (не входит в recordSize)
signature64 [64] bytes — подпись Ed25519 над hash32.
hash32 [32] bytes — SHA-256 от preimage (см. ниже).
2) Как считается хэш и что подписываем (BchCryptoVerifier)
preimage =
"SHiNE" (ASCII)
loginLen[1] + loginBytes[loginLen] (UTF-8, 1..255)
prevGlobalHash32[32]
prevLineHash32[32]
rawBytes[recordSize]
hash32 = SHA-256(preimage)
Далее верификация:
hash32 должен совпасть с hash32, записанным в блоке.
signature64 проверяется как Ed25519 подпись над hash32 публичным ключом пользователя.
3) Типы body и разновидности (по 1 предложению на тип)
HeaderBody (type=0) — генезис/идентификация блокчейна: фиксирует владельца (login) и тег формата.
TextBody (type=1) — текстовое сообщение: либо новое, либо ответ (reply), либо репост (repost) со ссылкой на целевой блок.
ReactionBody (type=2) — реакция на конкретный блок (в MVP — лайк) по ссылке на блок.
ConnectionBody (type=3) — событие связи с другим пользователем (friend/contact/follow) или отмена этой связи.
UserParamBody (type=4) — изменение/заявление одного параметра профиля в формате key/value.
4) Общий формат bodyBytes (для всех body)
type [2] int16 — код типа тела (0..4).
version [2] int16 — версия формата конкретного типа (сейчас везде 1).
subType [2] uint16 — подтип внутри типа (для Header всегда 0; для Text — NEW/REPLY/REPOST; для Reaction — LIKE; для Connection — set/unset + вид; для UserParam — TEXT_TEXT).
payload [N] bytes — поля конкретного body (строго по формату; “мусор” в конце запрещён).
5) Формат каждого типа body (по 1 строке на поле)
5.1 HeaderBody (type=0, ver=1, lineIndex=0)
type [2] — 0.
version [2] — 1.
subType [2] — 0 (compat).
tag [5] — ASCII "SHiNE".
loginLen [1] — длина login в UTF-8 (1..255).
login [N] — login UTF-8 (^[A-Za-z0-9_]+$).
5.2 TextBody (type=1, ver=1, lineIndex=1)
type [2] — 1.
version [2] — 1.
subType [2] — 1=NEW, 2=REPLY, 3=REPOST.
textLenBytes [2] — длина текста в байтах UTF-8 (1..65535).
text [N] — текст UTF-8 (валидный, не blank).
toBlockchainNameLen [1] — (только для REPLY/REPOST) длина имени блокчейна цели (1..255).
toBlockchainName [N] — (только для REPLY/REPOST) UTF-8 имя блокчейна цели.
toBlockGlobalNumber [4] — (только для REPLY/REPOST) globalNumber целевого блока.
toBlockHash32 [32] — (только для REPLY/REPOST) raw-хэш целевого блока.
5.3 ReactionBody (type=2, ver=1, lineIndex=2)
type [2] — 2.
version [2] — 1.
subType [2] — 1=LIKE (зарезервировано под будущие реакции).
toBlockchainNameLen [1] — длина имени блокчейна цели (1..255).
toBlockchainName [N] — UTF-8 имя блокчейна цели.
toBlockGlobalNumber [4] — globalNumber целевого блока.
toBlockHash32 [32] — raw-хэш целевого блока.
5.4 ConnectionBody (type=3, ver=1, lineIndex=3)
type [2] — 3.
version [2] — 1.
subType [2] — 10/20/30 (FRIEND/CONTACT/FOLLOW) или 11/21/31 (UNFRIEND/UNCONTACT/UNFOLLOW).
toLoginLen [1] — длина login цели (1..255).
toLogin [N] — UTF-8 login цели (^[A-Za-z0-9_]+$).
toBlockchainNameLen [1] — длина имени блокчейна цели (1..255).
toBlockchainName [N] — UTF-8 имя блокчейна цели (снимок/якорь).
toBlockGlobalNumber [4] — lastKnown globalNumber у цели (снимок/якорь).
toBlockHash32 [32] — lastKnown hash у цели (снимок/якорь).
5.5 UserParamBody (type=4, ver=1, lineIndex=4)
type [2] — 4.
version [2] — 1.
subType [2] — 1=TEXT_TEXT.
keyLenBytes [2] — длина ключа в байтах UTF-8 (1..65535).
keyUtf8 [N] — ключ параметра UTF-8 (валидный, не blank).
valueLenBytes [2] — длина значения в байтах UTF-8 (1..65535).
valueUtf8 [M] — значение UTF-8 (валидное, не blank).
6) Канонические линии (LineIndex)
0 HEADER — генезис/идентификация.
1 TEXT — сообщения.
2 REACTION — реакции.
3 CONNECTION — связи.
4 USER_PARAM — параметры профиля.

View File

@ -1,2 +0,0 @@
НАПИШИ ВНАЧАЛЕ ФОРМАТ ОБЩЕГО ЗАГЛАВИЯ.
А ПОТОМ ФОРМАТ ПО КАЖДОМУ ТИПУ (И В НЁМ СУБТИПУ БЛОКОВ) ДЛЯ ЧЕГО НАДО, ЧТО ХРАНИТЬСЯ, КАКИЕ ПРАВИЛА И ОСОБЕННОСТИ ЗАПОЛНЕНИЯ

View File

@ -1,39 +0,0 @@
Net_AuthChallenge_Request — запрос вызова авторизации
Net_CreateAuthSession_Request — создание авторизационной сессии
Net_CloseAuthSession_Request — закрытие (завершение) сессии
Net_ListSessions_Request — получение списка активных сессий
Net_RefreshSession_Request — обновление / продление сессии
Net_AuthChallenge_Request — запрос вызова авторизации
Net_CreateAuthSession_Request — создание авторизационной сессии
закрытие сессии
Net_CloseAuthSession_Request — закрытие (завершение) сессии
елси уже авторифицирован то:
Net_CloseAuthSession_Request с параметром номер сесии или без параметра если прям эту сесиию
если не авторифицирован то:
Net_AuthChallenge_Request — запрос вызова авторизации
Net_CloseAuthSession_Request с параметром номер сесии для закрытия и время и цп для подтвержедния
получение списка сессий
Net_AuthChallenge_Request — запрос вызова авторизации
Net_ListSessions_Request — получение списка активных сессий
Net_RefreshSession_Request — обновление / продление сессии
план что сделать
получить список сесссий
и удалить сессию
при новом подключении или при активной сесии

Some files were not shown because too many files have changed in this diff Show More