From 7564c8d092e41269a54051b079e1515deed177d9 Mon Sep 17 00:00:00 2001 From: ua1zbe Date: Mon, 17 Aug 2026 16:58:00 +0300 Subject: [PATCH] Add APRS weather stations (experimental): receive meteo in 250 km radius, show temperature in main screen line --- .../java/ru/ua1zbe/svxremote/MainActivity.kt | 37 +++- .../ru/ua1zbe/svxremote/SettingsActivity.kt | 8 + .../ru/ua1zbe/svxremote/aprs/AprsMeteo.kt | 159 ++++++++++++++++++ .../java/ru/ua1zbe/svxremote/prefs/Prefs.kt | 18 ++ app/src/main/res/layout/activity_settings.xml | 57 +++++++ app/src/main/res/values/strings.xml | 6 + 6 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/ru/ua1zbe/svxremote/aprs/AprsMeteo.kt diff --git a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt index 6817e0f..fd07915 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt @@ -29,6 +29,7 @@ import android.widget.ProgressBar import android.widget.TextView import android.widget.Toast import java.io.File +import ru.ua1zbe.svxremote.aprs.AprsMeteo import ru.ua1zbe.svxremote.prefs.ConnectionSettings import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.proto.MsgParser @@ -92,6 +93,28 @@ class MainActivity : Activity(), SvxService.Listener { private var pendingUpdate: UpdateChecker.UpdateInfo? = null private var updateDialog: AlertDialog? = null + // ---------- APRS метеостанции (экспериментально) ---------- + private var aprsMeteo: AprsMeteo? = null + private var lastMeteo: String? = null + private var lastAprsCall = "" + private var lastAprsCoords = "" + + private fun startAprsMeteo() { + aprsMeteo?.stop() + aprsMeteo = null + val call = Prefs.aprsCallsign(this).trim() + if (call.isEmpty()) return + val coords = Prefs.aprsCoords(this) + .split(',') + .mapNotNull { it.trim().toDoubleOrNull() } + .take(2) + .let { if (it.size == 2) it[0] to it[1] else null } + aprsMeteo = AprsMeteo(call, coords) { wx -> + lastMeteo = wx + lblSubtitle.text = wx + }.also { it.start() } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) SplashActivity.mainAlive = true @@ -200,6 +223,9 @@ class MainActivity : Activity(), SvxService.Listener { }) refreshUi() + startAprsMeteo() + lastAprsCall = Prefs.aprsCallsign(this).trim() + lastAprsCoords = Prefs.aprsCoords(this).trim() } private var autoConnectAttempted = false @@ -223,6 +249,13 @@ class MainActivity : Activity(), SvxService.Listener { } } refreshUi() + val aprsCall = Prefs.aprsCallsign(this).trim() + val aprsCoords = Prefs.aprsCoords(this).trim() + if (aprsCall != lastAprsCall || aprsCoords != lastAprsCoords) { + lastAprsCall = aprsCall + lastAprsCoords = aprsCoords + startAprsMeteo() + } if (!autoConnectAttempted && settings.host.isNotBlank() && settings.callsign.isNotBlank() @@ -239,6 +272,8 @@ class MainActivity : Activity(), SvxService.Listener { override fun onDestroy() { super.onDestroy() hideNodeOverlay() + aprsMeteo?.stop() + aprsMeteo = null mainHandler.removeCallbacksAndMessages(null) SplashActivity.mainAlive = false svc?.listener = null @@ -247,7 +282,7 @@ class MainActivity : Activity(), SvxService.Listener { private fun refreshUi() { val host = settings.host - lblSubtitle.text = if (host.isBlank()) { + lblSubtitle.text = lastMeteo ?: if (host.isBlank()) { getString(R.string.settings_not_configured) } else { getString(R.string.server_subtitle, host, settings.port, settings.talkGroup) diff --git a/app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt b/app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt index c91333c..985f3be 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt @@ -23,6 +23,8 @@ class SettingsActivity : Activity() { private lateinit var inputAuthKey: EditText private lateinit var inputTg: EditText private lateinit var inputGroups: EditText + private lateinit var inputAprsCallsign: EditText + private lateinit var inputAprsCoords: EditText private var currentProfile = 0 override fun onCreate(savedInstanceState: Bundle?) { @@ -36,8 +38,12 @@ class SettingsActivity : Activity() { inputAuthKey = findViewById(R.id.input_auth_key) inputTg = findViewById(R.id.input_tg) inputGroups = findViewById(R.id.input_groups) + inputAprsCallsign = findViewById(R.id.input_aprs_callsign) + inputAprsCoords = findViewById(R.id.input_aprs_coords) val chkNodeToasts = findViewById(R.id.chk_node_toasts) chkNodeToasts.isChecked = Prefs.nodeToastsEnabled(this) + inputAprsCallsign.setText(Prefs.aprsCallsign(this)) + inputAprsCoords.setText(Prefs.aprsCoords(this)) val profileNames = (1..Prefs.PROFILE_COUNT) .map { getString(R.string.profile_name, it) } @@ -88,6 +94,8 @@ class SettingsActivity : Activity() { Prefs.saveProfile(this, currentProfile, profile) Prefs.setActiveProfile(this, currentProfile) Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked) + Prefs.setAprsCallsign(this, inputAprsCallsign.text.toString()) + Prefs.setAprsCoords(this, inputAprsCoords.text.toString()) toast(getString(R.string.saved)) finish() } diff --git a/app/src/main/java/ru/ua1zbe/svxremote/aprs/AprsMeteo.kt b/app/src/main/java/ru/ua1zbe/svxremote/aprs/AprsMeteo.kt new file mode 100644 index 0000000..65bc6a9 --- /dev/null +++ b/app/src/main/java/ru/ua1zbe/svxremote/aprs/AprsMeteo.kt @@ -0,0 +1,159 @@ +package ru.ua1zbe.svxremote.aprs + +import android.os.Handler +import android.os.Looper +import android.util.Log +import ru.ua1zbe.svxremote.BuildConfig +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.InetSocketAddress +import java.net.Socket + +/** + * Экспериментально: приём метеостанций с APRS-IS (aprs2.net) в радиусе 250 км + * от позывного (фильтр b/) или от координат (фильтр r/). Весь код — в этом + * файле, удаляется вместе с вызовом в MainActivity и полями в настройках. + */ +class AprsMeteo( + private val callsign: String, + private val coords: Pair?, + private val onWeather: (String) -> Unit +) { + private val tag = "AprsMeteo" + private val main = Handler(Looper.getMainLooper()) + @Volatile private var running = false + private var hostIdx = 0 + + fun start() { + if (running) return + running = true + Thread({ run() }, "aprs-meteo").start() + } + + fun stop() { + running = false + } + + private fun run() { + while (running) { + try { + runOnce() + } catch (e: Exception) { + if (running) Log.d(tag, "err: ${e.message}") + } + if (running) { + try { + Thread.sleep(30_000) + } catch (_: InterruptedException) { + break + } + } + } + } + + private fun runOnce() { + val sock = Socket() + try { + sock.connect(InetSocketAddress(APRS_HOSTS[hostIdx], APRS_PORT), 10_000) + sock.soTimeout = 90_000 + val filter = if (coords != null) { + "r/${coords.first}/${coords.second}/250" + } else { + "b/$callsign/250" + } + // ВАЖНО: без "vers" — сервер пользователя (T2SPB/T2APATITY) + // не принимает фильтр и молчит; с "vers" — фильтр игнорируется + val login = "user $callsign pass ${passcode(callsign)} " + + "filter $filter\r\n" + sock.getOutputStream().write(login.toByteArray()) + sock.getOutputStream().flush() + Log.d(tag, "connected to ${APRS_HOSTS[hostIdx]}, filter=$filter") + val reader = BufferedReader(InputStreamReader(sock.getInputStream())) + val start = System.currentTimeMillis() + var sawPacket = false + while (running) { + val line = try { + reader.readLine() + } catch (e: java.net.SocketTimeoutException) { + // Нет строк (даже heartbeat) — канал мёртв, переподключаемся + break + } ?: break + if (line.startsWith("#")) { + if (line.contains("logresp")) { + val server = line.substringAfter("server", "?").trim() + Log.d(tag, "server=$server") + } + // Сервер шлёт heartbeat, но не отдаёт пакеты. На основном + // сервере слушаем долго (станции передают редко), на + // запасном — 3 минуты, и возвращаемся на основной + val wait = if (hostIdx == 0) 900_000L else 180_000L + if (!sawPacket && System.currentTimeMillis() - start > wait) break + continue + } + sawPacket = true + if (BuildConfig.DEBUG) Log.d(tag, "pkt: " + line.substringBefore('>')) + parse(line)?.let { wx -> + Log.d(tag, "wx: $wx") + main.post { onWeather(wx) } + } + } + // Сервер принял логин, но не отдал ни одного пакета (аплинк молчит) — + // в следующий раз пробуем следующий сервер. С пакетами — возвращаемся + // к первому (сервер пользователя) + hostIdx = if (sawPacket) 0 else (hostIdx + 1) % APRS_HOSTS.size + } finally { + try { + sock.close() + } catch (_: Exception) { + } + } + } + + /** Парсинг: CALL>...:!|@|=_<метео> -> " Температура C". */ + private fun parse(line: String): String? { + if (line.startsWith("#") || line.isEmpty()) return null + // Разделитель заголовка — ПЕРВЫЙ ':' после '>'; в info могут быть свои ':' + // (например "Temp: 16.4°C Uptime: 319h") + val gt = line.indexOf('>') + val idx = line.indexOf(':', gt) + if (idx < 0 || idx + 1 >= line.length) return null + val body = line.substring(idx + 1) + if (body.isEmpty() || body[0] !in "!@=") return null + val temp = parseTemp(body) ?: return null + val t = if (temp == temp.toInt().toDouble()) temp.toInt().toString() else temp.toString() + return line.substringBefore('>') + " Температура " + t + "C" + } + + /** Температура из метео-части после символа '_': t062=6.2, t06=6, t-08=-0.8, T083=8.3. */ + private fun parseTemp(body: String): Double? { + val u = body.indexOf('_') + if (u < 0) return null + val meta = body.substring(u + 1).substringBefore(' ') // до комментария + val m = Regex("[tT]([+-]?)(\\d{2,3})").find(meta) ?: return null + val sign = if (m.groupValues[1] == "-") -1 else 1 + val d = m.groupValues[2] + val v = if (d.length == 3) { + d.substring(0, 2).toInt() + d.substring(2).toInt() / 10.0 + } else { + d.toInt().toDouble() + } + return sign * v + } + + /** APRS-IS passcode по позывному (алгоритм aprsc). */ + private fun passcode(call: String): Int { + var hash = 0x73E2 + for (c in call.uppercase()) { + hash = hash xor (c.code shl 8) + hash = ((hash and 0xFFFF) * 0x89) and 0xFFFF + } + return hash and 0x7FFF + } + + companion object { + // Сначала сервер пользователя; если он не отдаёт трафик (аплинк + // молчит), переходим на публичный aprs2.net + private val APRS_HOSTS = listOf("rotate.radiorubka.org", "rotate.aprs2.net") + private const val APRS_PORT = 14580 + } +} diff --git a/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt b/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt index 5359d9b..74f47ff 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt @@ -126,4 +126,22 @@ object Prefs { fun setNodeToastsEnabled(context: Context, enabled: Boolean) { sp(context).edit().putBoolean("node_toasts", enabled).apply() } + + // ---------- APRS метеостанции (экспериментально) ---------- + + /** Позывной для APRS (пусто = функция выключена). */ + fun aprsCallsign(context: Context): String = + sp(context).getString("aprs_callsign", "") ?: "" + + fun setAprsCallsign(context: Context, callsign: String) { + sp(context).edit().putString("aprs_callsign", callsign.trim()).apply() + } + + /** Координаты "lat,lon" (пусто = фильтр b/ по позывному). */ + fun aprsCoords(context: Context): String = + sp(context).getString("aprs_coords", "") ?: "" + + fun setAprsCoords(context: Context, coords: String) { + sp(context).edit().putString("aprs_coords", coords.trim()).apply() + } } diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 846a275..b102eb1 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -165,6 +165,63 @@ android:textColor="@color/text_primary" android:textSize="15sp" /> + + + + + + + + + + + +