Document radio PTT ownership and talk event state contracts

This commit is contained in:
evgeny committed 2026-10-29 13:34:22 +01:00
1 parent 43b14ece78
commit 95894273f1
2 files changed
+26 -5

No files matched your search

@@ -1,16 +1,25 @@
package com.utun.chat.data
/** Независимые владельцы ручной передачи. Все операции выполняются в главном потоке. */
/** Источник ручной передачи: кнопка чата, плавающая кнопка либо общий владелец volume-клавиш. */
internal enum class RadioPttSource { BUTTON, OVERLAY, VOLUME }
/**
* Независимые удержания PTT без управления звуком и таймерами; все вызовы сериализует main-поток владельца.
* Каждый источник занимает одно место: повтор press не увеличивает счётчик. Несколько физических клавиш
* одного источника вызывающий агрегирует сам и делает release только после отпускания последней.
* RadioAudioEngine использует release для хвоста передачи, cancel — для немедленной отмены без хвоста.
*/
internal class RadioPttHolds {
private val held = mutableSetOf<RadioPttSource>()
private val held = mutableSetOf<RadioPttSource>() // Только ручные владельцы; VAD/автоматика сюда не входят.
/** Добавить source идемпотентно; остальные владельцы сохраняются. */
fun press(source: RadioPttSource) { held.add(source) }
/** Завершать передачу можно только после отпускания последнего владельца. */
/** Удалить source; true только при удалении последнего владельца. Неизвестный/повторный release даёт false. */
fun release(source: RadioPttSource): Boolean = held.remove(source) && held.isEmpty()
/** Удалить source либо всех при null; вернуть отсутствие владельцев, даже если набор уже был пуст.
* Поэтому cancel разрешает снять отложенный хвост передачи также после предыдущего release. */
fun cancel(source: RadioPttSource? = null): Boolean {
if (source == null) held.clear() else held.remove(source)
return held.isEmpty()
@@ -3,17 +3,29 @@ package com.utun.chat.data
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Эфемерные говорящие одной активной рации; снимки участников и локальный mute не меняют этот набор. */
/**
* Снимок говорящих одной рации: groupId=0 отключает обработку событий, speakers содержит node ID.
* При включении/смене канала вызывающий создаёт новый RadioTalkState(group), при выключении — RadioTalkState().
* Снимки участников и локальный mute независимы от индикации речи; переданный speakers нельзя мутировать извне.
* C-ядро хранит одного talker на узел: смена stream даёт новый START без STOP прежнего stream.
* Поэтому набор учитывает nodeId, а stream служит диагностике и не создаёт отдельного говорящего.
* ID — ненулевые 64-битные значения; отрицательный Long сохраняет unsigned-биты протокольного ID.
*/
data class RadioTalkState(val groupId: Long = 0, val speakers: Set<Long> = emptySet()) {
/** Применить валидное событие парсера; чужая/выключенная группа и повтор состояния возвращают this.
* START добавляет узел, STOP удаляет только его; прежний снимок остаётся неизменным. */
fun onTalk(event: RadioTalkEvent): RadioTalkState {
if (groupId == 0L || event.groupId != groupId || speakers.contains(event.nodeId) == event.on) return this
return copy(speakers = if (event.on) speakers + event.nodeId else speakers - event.nodeId)
}
}
/** Одно событие речи: ненулевые ID группы/узла, stream=0..65535, on=true для START и false для STOP. */
data class RadioTalkEvent(val groupId: Long, val nodeId: Long, val stream: Int, val on: Boolean)
/** Строгий формат CHAT_EVT_RADIO_TALK: group(8), source(8), stream(2), on(1), little endian. */
/** Разобрать ровно 19 байтов CHAT_EVT_RADIO_TALK: group(8), source(8), stream(2), on(1), little endian.
* Только on=0/1 и ненулевые group/source; stream допускает весь uint16, включая 0.
* При null, неверной длине/флаге/нулевом ID вернуть null; диагностику отказа пишет вызывающий ChatViewModel. */
fun parseRadioTalkEvent(data: ByteArray?): RadioTalkEvent? {
if (data == null || data.size != 19 || data[18].toInt() !in 0..1) return null
val bytes = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)