initial commit

This commit is contained in:
Jon Vanvik 2026-05-31 14:09:00 +02:00
commit ca3d7d05e0
56 changed files with 3875 additions and 0 deletions

92
HANDOFF.md Normal file
View file

@ -0,0 +1,92 @@
# Handoff — PON Go Android
Context for the next session/developer. Pairs with [`README.md`](README.md) and
[`docs/MCMS_API.md`](docs/MCMS_API.md).
## Architecture
- **`core/`** — pure Kotlin, no Android UI deps. Mirrors the iOS networking core.
- JSON: MCMS docs are heterogeneous Mongo documents, so we keep raw
`kotlinx.serialization` `JsonElement` trees and read fields via the accessors
in `Json.kt` (`el["key"]`, `.string/.int/.double/.array/.obj`) — the analogue
of the iOS `JSONValue`. Models (`Models.kt`) are thin wrappers over a
`JsonElement` with computed properties.
- `ApiClient` (OkHttp): builds versioned requests, injects `Referer` +
`X-CSRFToken` (writes), unwraps the `{status,data}` envelope, maps status
codes to `ApiError`, paginates via the `next` cursor. `Call.await()` bridges
OkHttp to coroutines with cancellation.
- Repositories return model wrappers; `McmsConnection` wires it all; auth/cookies
in `Networking.kt` (`SessionCookieJar` + `SessionStore`), TLS policy in
`ServerTrust`, secrets in `KeychainStore` (EncryptedSharedPreferences).
- **`ui/`** — Jetpack Compose. Navigation is **state-based** in `MainActivity`
(a `List<Screen>` back stack + `BackHandler`), not Navigation-Compose — so
detail screens and the dashboard drill-downs can take in-memory objects
directly. Each screen has a plain **state-holder class** (`…Model`) exposing
`mutableStateOf` fields + `suspend load()/refresh()` — the analogue of the iOS
`@Observable` view models. No androidx `ViewModel`/DI.
## Build / verify loop (this matters)
The dev machine had **no `kotlinc`/`gradle` on PATH**, but Android Studio provides
everything. Two loops:
- **Full build (authoritative):**
```
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
./gradlew :app:assembleDebug --no-daemon --console=plain
```
Needs `local.properties` (`sdk.dir=…`, git-ignored). First run downloads deps
to `~/.gradle`; afterwards it's ~2060 s. This is what proves the whole app
(UI + resources + manifest + icons) compiles.
- **Quick core-only check (no Gradle), when iterating on `core/`:** the bundled
compiler at `…/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc`
with `-Xplugin=…/kotlinc/lib/kotlinx-serialization-compiler-plugin.jar` and a
classpath of Maven jars (kotlinx-serialization-json/core, okhttp, okio-jvm,
kotlinx-coroutines-core-jvm). Compiles every `core/*.kt` except
`KeychainStore.kt` (androidx). Faster than a Gradle round-trip.
## API gotchas (all handled in code — don't regress)
- **Single-encode URLs.** Ids stay RAW in `McmsEndpoint`; `ApiClient` encodes the
path once via `HttpUrl.addPathSegments`. Pre-encoding double-encoded MAC colons
(`:``%3A``%253A`) → MCMS "invalid id".
- **401 bounces to login; 403 does NOT** (permission/CSRF — `requiresReauthentication`
is 401-only).
- **Writes return `{"status":"success"}` with no `data`** — the unwrap tolerates it.
- **ONU registration lives on OLT-STATE buckets** (`OltRepository.registrationMap`),
not ONU-STATE. Friendly names are `ONU.Name` / `OLT.Name` (NOT `NETCONF.Name`).
- **CPE DHCP** comes from the web helper `GET /api/cpe/onu/<id>/` — a versionless,
bare `[code, cpe…]` array (use `ApiClient.getRaw`).
- **Firmware** = Procedure 7: GET ONU-CFG → write filename/version into the
**inactive** bank → full-doc PUT (`FirmwareUpgrade.apply`). Inactive-bank only,
so service stays up until the ONU reboots. Still worth testing on a spare ONU.
- Field paths were confirmed against live JSON; see `docs/MCMS_API.md` and the
model accessors.
## Trimmed vs iOS (intentional, easy to restore)
- ONU detail tabs are **Overview / CPE / Logs / Config** — no separate Alarms tab.
Current alarms live on ONU-STATE `"Alarm"` buckets if you want to add it.
- **Config** shows pretty-printed JSON text, not the collapsible tree the iOS app
has.
- Dashboard **ONU-state rows aren't tappable** (the Health rows are). The iOS app
drills ONU-state → filtered ONU list.
- Toggles use `rememberSaveable` (survive rotation, not process death). Persist in
prefs if you want parity with iOS `@AppStorage`.
- Self-signed/cleartext: the TLS trust policy is in `ServerTrust`. Plain-HTTP hosts
also need a `usesCleartextTraffic`/network-security-config exception (Android's
ATS analogue) — add when needed.
## Repo split
This folder is self-contained (has its own `docs/MCMS_API.md`). To make it a git
repo: `git init` here; the included `.gitignore` already excludes `build/`,
`.gradle/`, `local.properties`, `.idea/`. Commit the Gradle wrapper
(`gradlew`, `gradle/wrapper/*`). The full dev-guide PDFs (`323-1961-30x`) live in
the iOS repo if you ever need the deep source.
## Possible next steps
Restore the trimmed items above; add OLT firmware (Procedure 8 bulk task);
per-ONU live-alarms view; charts for PM history; pull-to-refresh on lists;
dark-theme polish; a proper app theme from the brand colours.

54
README.md Normal file
View file

@ -0,0 +1,54 @@
# PON Go — Android
Native Android app for Ciena **MCMS 6.2 PON Manager** basics on the go — dashboard,
ONU and OLT browsing/diagnostics, firmware updates, resets. Kotlin + Jetpack
Compose + OkHttp. Sibling of the iOS app (`../mcms_ios`); the REST behaviour and
field paths are identical and documented in [`docs/MCMS_API.md`](docs/MCMS_API.md).
**Status: complete and building.** `./gradlew :app:assembleDebug` → `BUILD
SUCCESSFUL`, runs in the emulator. Package `no.svorka.mcms`, app name **PON Go**.
## Build & run
1. Open the folder in **Android Studio** → let it sync → Run on a device/emulator
that can reach your MCMS host.
2. Or CLI: `./gradlew :app:assembleDebug` (needs `local.properties` with
`sdk.dir=…`, and a JDK 17+ — Android Studio's bundled JBR works).
Verified toolchain on the dev machine: **AGP 8.11.0, Kotlin 2.0.21, Gradle 8.13,
compileSdk 36, minSdk 26, JBR 21**.
## What it does (feature parity with iOS)
- **Login / Settings** — URL normalisation (`/api` auto-appended), self-signed
TLS toggle, test-connection probe, remember-me (Android Keystore), sign out.
- **Dashboard** — ONU/OLT/controller counts; total OLT traffic; a **Health**
section (abnormal-Rx ONUs, laser-off OLTs) that drills into the affected
devices; ONU-state breakdown; **Detailed stats** (opt-in Rx scan) and
**Auto-refresh 30 s** toggles.
- **ONUs** — searchable list; detail with **Overview** (status, firmware +
update picker, optical/FEC, UNI ports, traffic), **CPE** (DHCPv4/v6 leases),
**Logs**, **Config** (raw JSON). Reset + firmware behind hardened,
acknowledge-to-enable, red confirmations.
- **OLTs** — list with up/down summary; detail with status/uptime, PON traffic,
environment, uplink (LLDP), registration buckets (→ ONU detail), reset.
## Layout
```
app/src/main/java/no/svorka/mcms/
MainActivity.kt root: state-based nav + bottom bar
core/ networking / models / repositories (≈ iOS core)
Json, ApiConfiguration, ApiError, ApiQuery, McmsEndpoint, Util,
Models, AuthModels, Firmware, Networking, ApiClient, Repositories,
McmsConnection, KeychainStore
ui/ Jetpack Compose
Theme, Types, Components, AppState,
AuthScreens (Login+Settings), DashboardScreen (+drill-downs),
OnuScreens (list+detail+firmware+reset), OltScreens (list+detail)
app/src/main/res/ adaptive launcher icon, strings (app_name), colors
docs/MCMS_API.md the MCMS REST field guide (single source of truth)
```
See [`HANDOFF.md`](HANDOFF.md) for architecture notes, the build/verify loop, the
API gotchas, and what's deliberately trimmed vs iOS.

61
app/build.gradle.kts Normal file
View file

@ -0,0 +1,61 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "no.svorka.mcms"
compileSdk = 36
defaultConfig {
applicationId = "no.svorka.mcms"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.02")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6")
implementation("androidx.navigation:navigation-compose:2.8.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}

4
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,4 @@
# Keep kotlinx.serialization metadata.
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class kotlinx.serialization.json.** { *; }

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MCMS">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,107 @@
package no.svorka.mcms
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import no.svorka.mcms.core.McmsConnection
import no.svorka.mcms.core.OltStateDoc
import no.svorka.mcms.core.OnuStateDoc
import no.svorka.mcms.ui.*
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val app = remember { AppState(applicationContext) }
PonGoTheme { RootApp(app) }
}
}
}
private sealed interface Screen {
data object Main : Screen
data class OnuDetail(val id: String) : Screen
data class OltDetail(val id: String, val title: String) : Screen
data class AbnormalRx(val onus: List<OnuStateDoc>) : Screen
data class LaserOff(val olts: List<OltStateDoc>) : Screen
}
@Composable
private fun RootApp(app: AppState) {
when {
!app.isConfigured -> SettingsScreen(app, initialSetup = true, onClose = {})
!app.isAuthenticated -> {
var showSettings by remember { mutableStateOf(false) }
if (showSettings) SettingsScreen(app, initialSetup = false, onClose = { showSettings = false })
else LoginScreen(app, onOpenSettings = { showSettings = true })
}
else -> {
val connection = app.connection
if (connection == null) { SettingsScreen(app, initialSetup = true, onClose = {}); return }
var stack by remember { mutableStateOf<List<Screen>>(listOf(Screen.Main)) }
BackHandler(enabled = stack.size > 1) { stack = stack.dropLast(1) }
when (val top = stack.last()) {
Screen.Main -> MainScaffold(
app, connection,
openOnu = { stack = stack + Screen.OnuDetail(it) },
openOlt = { id, t -> stack = stack + Screen.OltDetail(id, t) },
openAbnormalRx = { stack = stack + Screen.AbnormalRx(it) },
openLaserOff = { stack = stack + Screen.LaserOff(it) },
)
is Screen.OnuDetail -> OnuDetailScreen(connection, top.id, onBack = { stack = stack.dropLast(1) })
is Screen.OltDetail -> OltDetailScreen(connection, top.id, top.title, onBack = { stack = stack.dropLast(1) }, openOnu = { stack = stack + Screen.OnuDetail(it) })
is Screen.AbnormalRx -> AbnormalRxScreen(top.onus, onBack = { stack = stack.dropLast(1) }, openOnu = { stack = stack + Screen.OnuDetail(it) })
is Screen.LaserOff -> LaserOffScreen(top.olts, onBack = { stack = stack.dropLast(1) }, openOlt = { id, t -> stack = stack + Screen.OltDetail(id, t) })
}
}
}
}
@Composable
private fun MainScaffold(
app: AppState,
connection: McmsConnection,
openOnu: (String) -> Unit,
openOlt: (String, String) -> Unit,
openAbnormalRx: (List<OnuStateDoc>) -> Unit,
openLaserOff: (List<OltStateDoc>) -> Unit,
) {
var tab by rememberSaveable { mutableStateOf(0) }
Scaffold(bottomBar = {
NavigationBar {
NavigationBarItem(selected = tab == 0, onClick = { tab = 0 }, icon = { Icon(Icons.Filled.Dashboard, null) }, label = { Text("Dashboard") })
NavigationBarItem(selected = tab == 1, onClick = { tab = 1 }, icon = { Icon(Icons.Filled.Hub, null) }, label = { Text("ONUs") })
NavigationBarItem(selected = tab == 2, onClick = { tab = 2 }, icon = { Icon(Icons.Filled.Router, null) }, label = { Text("OLTs") })
NavigationBarItem(selected = tab == 3, onClick = { tab = 3 }, icon = { Icon(Icons.Filled.Settings, null) }, label = { Text("Settings") })
}
}) { padding ->
Box(Modifier.padding(padding)) {
when (tab) {
0 -> DashboardScreen(connection, openAbnormalRx, openLaserOff)
1 -> OnuListScreen(connection, openOnu)
2 -> OltListScreen(connection, openOlt)
else -> SettingsScreen(app, initialSetup = false, onClose = { tab = 0 })
}
}
}
}

View file

@ -0,0 +1,151 @@
package no.svorka.mcms.core
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** Suspend bridge for an OkHttp call, with coroutine cancellation. */
suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) { cont.resume(response) }
override fun onFailure(call: Call, e: IOException) {
if (!cont.isCancelled) cont.resumeWithException(e)
}
})
cont.invokeOnCancellation { runCatching { cancel() } }
}
/**
* Transport for the MCMS REST API. Builds versioned requests, injects auth
* headers, unwraps the `{status,data}` envelope, and maps status codes to
* [ApiError]. Returns raw [JsonElement] trees that the repositories wrap.
*/
class ApiClient(
private val config: ApiConfiguration,
private val session: SessionStore,
) {
/** Fired on 401 so the app can route back to login. */
var onAuthExpired: (() -> Unit)? = null
private val jsonMedia = "application/json".toMediaType()
private val client: OkHttpClient = OkHttpClient.Builder().apply {
cookieJar(session.cookieJar)
callTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
ServerTrust.apply(this, config.trustPolicy)
}.build()
suspend fun getElement(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY, version: String? = null): JsonElement =
unwrapData(perform("GET", endpoint, query, null, version))
suspend fun getList(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY, version: String? = null): List<JsonElement> =
getElement(endpoint, query, version).array ?: emptyList()
/** Raw body, bypassing the envelope — for the bare-array CPE helper. */
suspend fun getRaw(endpoint: McmsEndpoint, version: String? = null): JsonElement {
val resp = perform("GET", endpoint, ApiQuery.EMPTY, null, version)
return if (resp.body.isBlank()) JsonNull else MCMSJson.parseToJsonElement(resp.body)
}
suspend fun send(method: String, endpoint: McmsEndpoint, body: JsonElement, wrapInData: Boolean = true, version: String? = null): JsonElement {
val payload = if (wrapInData) buildJsonObject { put("data", body) } else body
return unwrapData(perform(method, endpoint, ApiQuery.EMPTY, payload.toString(), version))
}
suspend fun action(method: String, endpoint: McmsEndpoint, version: String? = null): JsonElement =
unwrapData(perform(method, endpoint, ApiQuery.EMPTY, null, version))
suspend fun delete(endpoint: McmsEndpoint, version: String? = null) {
perform("DELETE", endpoint, ApiQuery.EMPTY, null, version)
}
/** Paginates by the `next` cursor; page size 100 (field guide §3.4). */
suspend fun getAll(
endpoint: McmsEndpoint,
baseQuery: ApiQuery = ApiQuery.EMPTY,
pageLimit: Int = 100,
version: String? = null,
idOf: (JsonElement) -> String?,
): List<JsonElement> {
val all = mutableListOf<JsonElement>()
var cursor: String? = null
do {
val q = baseQuery.copy(limit = pageLimit, next = cursor)
val page = getList(endpoint, q, version)
all += page
cursor = if (page.size == pageLimit) page.lastOrNull()?.let(idOf) else null
} while (cursor != null)
return all
}
private data class Resp(val code: Int, val body: String)
private fun buildUrl(endpoint: McmsEndpoint, query: ApiQuery, version: String?): HttpUrl {
val base = config.baseUrl.toHttpUrlOrNull() ?: throw ApiError.InvalidUrl
val builder = base.newBuilder()
// Raw ids; addPathSegments encodes once and keeps ':' in MAC ids raw.
builder.addPathSegments(endpoint.path(version ?: config.apiVersion).removePrefix("/"))
for ((name, value) in query.items()) builder.addQueryParameter(name, value)
return builder.build()
}
private suspend fun perform(method: String, endpoint: McmsEndpoint, query: ApiQuery, bodyJson: String?, version: String?): Resp {
val url = buildUrl(endpoint, query, version)
val reqBody = when {
bodyJson != null -> bodyJson.toRequestBody(jsonMedia)
method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody()
else -> null
}
val builder = Request.Builder().url(url)
.header("Accept", "application/json")
.header("Referer", config.refererValue)
.method(method, reqBody)
if (bodyJson != null) builder.header("Content-Type", "application/json")
if (method != "GET") session.csrfToken()?.let { builder.header("X-CSRFToken", it) }
val response = try {
client.newCall(builder.build()).await()
} catch (e: SSLException) {
throw ApiError.ServerTrust
} catch (e: IOException) {
throw ApiError.Transport(e.message ?: "request failed")
}
val code = response.code
val body = response.body?.string() ?: ""
response.close()
if (code !in 200..299) {
val details = runCatching { MCMSJson.parseToJsonElement(body)["details"] }.getOrNull()
val error = ApiError.fromStatus(code, details)
if (error.requiresReauthentication) onAuthExpired?.invoke()
throw error
}
return Resp(code, body)
}
private fun unwrapData(resp: Resp): JsonElement {
if (resp.body.isBlank()) return JsonNull
val root = MCMSJson.parseToJsonElement(resp.body)
val status = root["status"].string
if (status != null && !status.equals("success", ignoreCase = true)) {
throw ApiError.ApiFailure(root["details"])
}
return root["data"] ?: JsonNull
}
}

View file

@ -0,0 +1,59 @@
package no.svorka.mcms.core
import kotlinx.serialization.Serializable
import java.net.URI
/** How to evaluate the server's TLS certificate. */
@Serializable
sealed class ServerTrustPolicy {
/** Default platform trust evaluation. */
@Serializable
data object System : ServerTrustPolicy()
/** Accept any certificate presented by this exact host (self-signed lab boxes). */
@Serializable
data class AllowSelfSignedForHost(val host: String) : ServerTrustPolicy()
/** Trust only if the leaf public-key SHA-256 (base64) matches one of these. */
@Serializable
data class PinPublicKeySha256(val pins: List<String>, val host: String) : ServerTrustPolicy()
}
/**
* Connection configuration for the MCMS REST API. URL-driven so the same build
* works direct or via reverse proxy. `baseUrl` includes the `/api` prefix, e.g.
* `https://mcms.lab.svorka.net/api`.
*/
@Serializable
data class ApiConfiguration(
val baseUrl: String,
val apiVersion: String = "v1",
val databaseId: String? = null,
val refererOverride: String? = null,
val sessionCookieName: String = "sessionid",
val csrfCookieName: String = "csrftoken",
val trustPolicy: ServerTrustPolicy = ServerTrustPolicy.System,
val timeoutSeconds: Long = 30,
) {
val refererValue: String get() = refererOverride ?: baseUrl
val host: String? get() = runCatching { URI(baseUrl).host }.getOrNull()
companion object {
/**
* Normalises a user-entered URL: requires scheme + host, trims a trailing
* slash, and defaults the path to `/api` when none is given. Returns null
* if it isn't a usable URL.
*/
fun normalizedBaseUrl(raw: String): String? {
val trimmed = raw.trim()
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
val scheme = uri.scheme ?: return null
val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null
var path = uri.path ?: ""
while (path.endsWith("/")) path = path.dropLast(1)
if (path.isEmpty()) path = "/api"
val port = if (uri.port >= 0) ":${uri.port}" else ""
return "$scheme://$host$port$path"
}
}
}

View file

@ -0,0 +1,38 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.JsonElement
/** Typed errors surfaced by [ApiClient]. */
sealed class ApiError(message: String) : Exception(message) {
data object InvalidUrl : ApiError("The request URL was invalid.")
data class Transport(val detail: String) : ApiError("Network error: $detail")
data object ServerTrust : ApiError("The server's TLS certificate could not be trusted.")
data class Encoding(val detail: String) : ApiError("Failed to encode request: $detail")
data class Decoding(val detail: String) : ApiError("Failed to read server response: $detail")
data class BadRequest(val details: JsonElement?) : ApiError("Request rejected (400). ${details.compactDescription()}")
data object Unauthorized : ApiError("Not authenticated (401). Please sign in again.")
data class Forbidden(val details: JsonElement?) : ApiError("Forbidden (403) — your account may not have permission for this action. ${details.compactDescription()}")
data object NotFound : ApiError("Resource not found (404).")
data class Conflict(val details: JsonElement?) : ApiError("Conflict (409) — ID already in use. ${details.compactDescription()}")
data object UnsupportedMediaType : ApiError("Unsupported media type (415).")
data class Server(val details: JsonElement?) : ApiError("Server error (500). ${details.compactDescription()}")
data class ApiFailure(val details: JsonElement?) : ApiError("Request failed. ${details.compactDescription()}")
data class UnexpectedStatus(val code: Int, val details: JsonElement?) : ApiError("Unexpected response ($code). ${details.compactDescription()}")
/** Only 401 should bounce to login; a 403 is usually a permission/CSRF issue. */
val requiresReauthentication: Boolean get() = this is Unauthorized
companion object {
fun fromStatus(code: Int, details: JsonElement?): ApiError = when (code) {
400 -> BadRequest(details)
401 -> Unauthorized
403 -> Forbidden(details)
404 -> NotFound
409 -> Conflict(details)
415 -> UnsupportedMediaType
500 -> Server(details)
else -> UnexpectedStatus(code, details)
}
}
}

View file

@ -0,0 +1,48 @@
package no.svorka.mcms.core
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Builder for the MCMS list-endpoint query parameters. `query`/`projection`/
* `sort` join as comma-separated `key=value` strings; `start-time`/`end-time`
* use the UTC `yyyy-MM-dd HH:mm:ss` format.
*/
data class ApiQuery(
val query: Map<String, String> = emptyMap(),
val projection: Map<String, Int> = emptyMap(),
val sort: Map<String, Int> = emptyMap(),
val limit: Int? = null,
val skip: Int? = null,
val next: String? = null,
val distinct: String? = null,
val startTime: Date? = null,
val endTime: Date? = null,
) {
fun items(): List<Pair<String, String>> {
val out = mutableListOf<Pair<String, String>>()
if (query.isNotEmpty()) out += "query" to query.entries.joinToString(",") { "${it.key}=${it.value}" }
if (projection.isNotEmpty()) out += "projection" to projection.entries.joinToString(",") { "${it.key}=${it.value}" }
if (sort.isNotEmpty()) out += "sort" to sort.entries.joinToString(",") { "${it.key}=${it.value}" }
limit?.let { out += "limit" to it.toString() }
skip?.let { out += "skip" to it.toString() }
next?.let { out += "next" to it }
distinct?.let { out += "distinct" to it }
startTime?.let { out += "start-time" to timeFormatter.format(it) }
endTime?.let { out += "end-time" to timeFormatter.format(it) }
return out
}
companion object {
val EMPTY = ApiQuery()
private val timeFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
fun window(lastHours: Int, limit: Int? = null): ApiQuery =
ApiQuery(startTime = Date(System.currentTimeMillis() - lastHours * 3_600_000L), limit = limit)
}
}

View file

@ -0,0 +1,9 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.JsonElement
/** `GET /ponmgr/version/` — best-effort display version. */
class VersionInfo(val raw: JsonElement) {
val displayVersion: String?
get() = raw["version"].string ?: raw["Version"].string ?: raw["ponmgr"]["version"].string
}

View file

@ -0,0 +1,62 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/** One uploaded ONU firmware image from `GET /files/onu-firmware/`. */
class FirmwareFile(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: filename ?: "?"
val filename: String? get() = raw["filename"].string ?: raw["_id"].string
val version: String? get() = raw["metadata"]["Version"].string
val manufacturer: String? get() = raw["metadata"]["Compatible Manufacturer"].string
val models: List<String> get() = raw["metadata"]["Compatible Model"].array?.mapNotNull { it.string } ?: emptyList()
val sizeBytes: Double? get() = raw["length"].double
val uploadDate: String? get() = raw["uploadDate"].string
/** Manufacturer must match the ONU vendor and the equipment id must be an
* exact (not substring) member of Compatible Model. */
fun isCompatible(vendor: String?, equipmentId: String?): Boolean {
val mfg = manufacturer
if (vendor != null && !mfg.isNullOrEmpty() && !mfg.equals(vendor, ignoreCase = true)) return false
if (equipmentId == null || models.isEmpty()) return true
return models.any { it.equals(equipmentId, ignoreCase = true) }
}
}
/** Procedure 7 (per-ONU firmware upgrade) as a pure transform. */
object FirmwareUpgrade {
data class Result(val document: JsonElement, val slot: Int)
/** Stage `filename`/`version` in the ONU's INACTIVE bank and point at it,
* preserving every other field (full-doc PUT). Ptr 01, 10, else1. */
fun apply(config: JsonElement, filename: String, version: String): Result? {
val root = config as? JsonObject ?: return null
val onu = root["ONU"] as? JsonObject ?: return null
val currentPtr = onu["FW Bank Ptr"].int ?: 65535
val slot = if (currentPtr == 1) 0 else 1
val files = (onu["FW Bank Files"] as? JsonArray)?.toMutableList() ?: mutableListOf()
val versions = (onu["FW Bank Versions"] as? JsonArray)?.toMutableList() ?: mutableListOf()
while (files.size <= slot) files.add(JsonPrimitive(""))
while (versions.size <= slot) versions.add(JsonPrimitive(""))
files[slot] = JsonPrimitive(filename)
versions[slot] = JsonPrimitive(version)
val newOnu = buildJsonObject {
onu.forEach { (k, v) -> put(k, v) }
put("FW Bank Files", JsonArray(files))
put("FW Bank Versions", JsonArray(versions))
put("FW Bank Ptr", JsonPrimitive(slot))
}
val newRoot = buildJsonObject {
root.forEach { (k, v) -> put(k, v) }
put("ONU", newOnu)
}
return Result(newRoot, slot)
}
}

View file

@ -0,0 +1,55 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
/**
* Shared lenient JSON. MCMS documents are heterogeneous Mongo docs, so we work
* with raw [JsonElement] trees (the Kotlin analogue of the iOS `JSONValue`) and
* read fields through the accessors below rather than modelling every field.
*/
val MCMSJson: Json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
}
/** Object-member access that tolerates a null receiver and non-object values. */
operator fun JsonElement?.get(key: String): JsonElement? =
(this as? JsonObject)?.get(key)
val JsonElement?.string: String?
get() = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
val JsonElement?.int: Int?
get() = (this as? JsonPrimitive)?.let { it.intOrNull ?: it.doubleOrNull?.toInt() }
val JsonElement?.double: Double?
get() = (this as? JsonPrimitive)?.let { it.doubleOrNull ?: it.intOrNull?.toDouble() }
val JsonElement?.bool: Boolean?
get() = (this as? JsonPrimitive)?.booleanOrNull
val JsonElement?.array: List<JsonElement>?
get() = this as? JsonArray
val JsonElement?.obj: Map<String, JsonElement>?
get() = this as? JsonObject
val JsonElement?.isObject: Boolean
get() = this is JsonObject
/** A compact human rendering for error/detail surfaces. */
fun JsonElement?.compactDescription(): String = when (val e = this) {
null, JsonNull -> "null"
is JsonPrimitive -> e.content
is JsonArray -> e.joinToString(", ") { it.compactDescription() }
is JsonObject -> e.entries.joinToString("; ") { "${it.key}: ${it.value.compactDescription()}" }
}

View file

@ -0,0 +1,29 @@
package no.svorka.mcms.core
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Encrypted credential storage (Android Keystore-backed), the analogue of the
* iOS Keychain wrapper. Never store secrets in plain SharedPreferences.
*/
class KeychainStore(context: Context) {
private val prefs: SharedPreferences = run {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"mcms_credentials",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun get(key: String): String? = prefs.getString(key, null)
fun set(key: String, value: String) { prefs.edit().putString(key, value).apply() }
fun delete(key: String) { prefs.edit().remove(key).apply() }
}

View file

@ -0,0 +1,17 @@
package no.svorka.mcms.core
/** Builds and holds the networking stack for one MCMS connection. */
class McmsConnection(val config: ApiConfiguration) {
val session = SessionStore(config)
val client = ApiClient(config, session)
val auth = AuthRepository(client, session, config)
val onu = OnuRepository(client)
val olt = OltRepository(client)
val controller = ControllerRepository(client)
/** Fired when a request returns 401, so the app can route back to login. */
var onAuthExpired: (() -> Unit)?
get() = client.onAuthExpired
set(value) { client.onAuthExpired = value }
}

View file

@ -0,0 +1,74 @@
package no.svorka.mcms.core
/**
* Versioned endpoint paths (relative to `baseUrl`). IDs are kept RAW here the
* URL builder in [ApiClient] percent-encodes the whole path exactly once.
* Pre-encoding double-encoded MAC colons (`:` `%3A` `%253A`), which MCMS
* rejected as an invalid id.
*/
sealed class McmsEndpoint {
data object Authenticate : McmsEndpoint()
data object Logout : McmsEndpoint()
data object DatabaseSelection : McmsEndpoint()
data object Version : McmsEndpoint()
data object ControllerConfigs : McmsEndpoint()
data class ControllerConfig(val id: String) : McmsEndpoint()
data class ControllerStats(val id: String) : McmsEndpoint()
data class ControllerLogs(val id: String) : McmsEndpoint()
data object OltConfigs : McmsEndpoint()
data class OltConfig(val id: String) : McmsEndpoint()
data object OltStates : McmsEndpoint()
data class OltState(val id: String) : McmsEndpoint()
data class OltStats(val id: String) : McmsEndpoint()
data class OltLogs(val id: String) : McmsEndpoint()
data class OltReset(val id: String) : McmsEndpoint()
data object OnuConfigs : McmsEndpoint()
data class OnuConfig(val id: String) : McmsEndpoint()
data object OnuStates : McmsEndpoint()
data class OnuState(val id: String) : McmsEndpoint()
data class OnuStats(val id: String) : McmsEndpoint()
data class OnuLogs(val id: String) : McmsEndpoint()
data object OnuAlarmHistories : McmsEndpoint()
data object OnuCpeStates : McmsEndpoint()
data class OnuReset(val id: String) : McmsEndpoint()
data object OnuFirmwareFiles : McmsEndpoint()
data class OnuCpe(val id: String) : McmsEndpoint() // versionless web helper
fun path(version: String): String {
val v = "/$version"
return when (this) {
Authenticate -> "$v/users/authenticate/"
Logout -> "$v/users/logout/"
DatabaseSelection -> "$v/databases/selection/"
Version -> "$v/ponmgr/version/"
ControllerConfigs -> "$v/controllers/configs/"
is ControllerConfig -> "$v/controllers/configs/$id/"
is ControllerStats -> "$v/controllers/stats/$id/"
is ControllerLogs -> "$v/controllers/logs/$id/"
OltConfigs -> "$v/olts/configs/"
is OltConfig -> "$v/olts/configs/$id/"
OltStates -> "$v/olts/states/"
is OltState -> "$v/olts/states/$id/"
is OltStats -> "$v/olts/stats/$id/"
is OltLogs -> "$v/olts/logs/$id/"
is OltReset -> "$v/olts/$id/reset/"
OnuConfigs -> "$v/onus/configs/"
is OnuConfig -> "$v/onus/configs/$id/"
OnuStates -> "$v/onus/states/"
is OnuState -> "$v/onus/states/$id/"
is OnuStats -> "$v/onus/stats/$id/"
is OnuLogs -> "$v/onus/logs/$id/"
OnuAlarmHistories -> "$v/onus/alarm-histories/"
OnuCpeStates -> "$v/onus/cpe-states/"
is OnuReset -> "$v/onus/$id/reset/"
OnuFirmwareFiles -> "$v/files/onu-firmware/"
is OnuCpe -> "/cpe/onu/$id/"
}
}
}

View file

@ -0,0 +1,212 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.JsonElement
// ───────────────────────── Enums ─────────────────────────
/** ONU registration buckets from OLT-STATE `"ONU States"` (dev guide Procedure 2). */
enum class OnuLifecycleState(val label: String) {
REGISTERED("Registered"),
UNSPECIFIED("Unspecified"),
UNPROVISIONED("Unprovisioned"),
DEREGISTERED("Deregistered"),
DYING_GASP("Dying Gasp"),
DISABLED("Disabled"),
DISALLOWED_ADMIN("Disallowed Admin"),
DISALLOWED_ERROR("Disallowed Error"),
DISALLOWED_REG_ID("Disallowed Reg ID"),
UNKNOWN("Unknown");
/** Registered or Unspecified counts as online (dev guide Procedure 2). */
val isOnline: Boolean get() = this == REGISTERED || this == UNSPECIFIED
companion object {
fun fromBucket(name: String): OnuLifecycleState =
entries.firstOrNull { it.label.equals(name, ignoreCase = true) } ?: UNKNOWN
}
}
enum class AlarmSeverity(val rank: Int, val label: String) {
EMERGENCY(0, "emergency"), ALERT(1, "alert"), CRITICAL(2, "critical"),
ERROR(3, "error"), WARNING(4, "warning"), NOTICE(5, "notice"),
INFO(6, "info"), DEBUG(7, "debug"), UNKNOWN(8, "unknown");
companion object {
fun from(raw: String?): AlarmSeverity =
entries.firstOrNull { it.label.equals(raw, ignoreCase = true) } ?: UNKNOWN
}
}
// ───────────────────────── ONU-STATE ─────────────────────────
class OnuStateDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val serialNumber: String? get() = raw["_id"].string
/** Parent OLT MAC, from `OLT.MAC Address` (dev guide Procedure 2). */
val parentOlt: String? get() = raw["OLT"]["MAC Address"].string
val lastUpdate: String? get() = raw["Time"].string
/** Registration lives on OLT-STATE; resolved + stamped by the list/dashboard VMs. */
var resolvedLifecycle: OnuLifecycleState? = null
val lifecycle: OnuLifecycleState get() = resolvedLifecycle ?: OnuLifecycleState.UNKNOWN
/** Operator name from ONU-CFG; stamped by the VM. */
var resolvedName: String? = null
val displayName: String get() = resolvedName?.takeIf { it.isNotEmpty() } ?: id
private val onu get() = raw["ONU"]
val registrationState: String? get() = onu["Registration State"].string
val equipmentId: String? get() = onu["Equipment ID"].string
val vendor: String? get() = onu["Vendor"].string
val ponMode: String? get() = onu["PON Mode"].string
val onlineTime: String? get() = onu["Online Time"].string
val temperatureC: Double? get() = raw["GPON"]["Temperature"].double
val fwVersion: String? get() = onu["FW Version"].string
val fwBankVersions: List<String> get() = onu["FW Bank Versions"].array?.mapNotNull { it.string } ?: emptyList()
val fwBankPtr: Int? get() = onu["FW Bank Ptr"].int
val fwUpgradeStatus: JsonElement? get() = onu["FW Upgrade Status"]?.takeIf { it.obj?.isNotEmpty() == true }
private fun ponStat(pon: String, key: String): Double? = raw["STATS"][pon][key].double
val rxOpticalDBm: Double? get() = ponStat("ONU-PON", "RX Optical Level")
val txOpticalDBm: Double? get() = ponStat("ONU-PON", "TX Optical Level")
val onuPreFec: Double? get() = ponStat("ONU-PON", "RX Pre-FEC BER")
val onuPostFec: Double? get() = ponStat("ONU-PON", "RX Post-FEC BER")
val oltPreFec: Double? get() = ponStat("OLT-PON", "RX Pre-FEC BER")
val oltPostFec: Double? get() = ponStat("OLT-PON", "RX Post-FEC BER")
val uniPorts: List<UniPort> get() = (raw.obj ?: emptyMap()).entries
.filter { it.key.startsWith("UNI-") }.sortedBy { it.key }
.map { UniPort(it.key, it.value) }
val ponServices: List<PonServiceStats> get() = (raw["STATS"].obj ?: emptyMap()).entries
.filter { it.key.startsWith("OLT-PON Service ") }.sortedBy { it.key }
.map { PonServiceStats(it.key, it.value) }
}
class UniPort(val name: String, val raw: JsonElement) {
val isEthernet: Boolean get() = name.contains("ETH", ignoreCase = true)
val enabled: Boolean? get() = raw["Enable"].bool
val speed: String? get() = raw["Speed"].string
val duplex: String? get() = raw["Duplex"].string
val state: String? get() = raw["State"].string
}
class PonServiceStats(val name: String, val raw: JsonElement) {
val rxRateBps: Double? get() = raw["RX Rate bps"].double
val txRateBps: Double? get() = raw["TX Rate bps"].double
val rxTotalOctets: Double? get() = raw["RX Total Octets"].double
val txTotalOctets: Double? get() = raw["TX Total Octets"].double
}
// ───────────────────────── ONU-CFG ─────────────────────────
class OnuConfigDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val name: String? get() = raw["ONU"]["Name"].string
}
// ───────────────────────── OLT-STATE / OLT-CFG ─────────────────────────
class OltStateDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val lastUpdate: String? get() = raw["Time"].string
var resolvedName: String? = null
val displayName: String get() = resolvedName?.takeIf { it.isNotEmpty() } ?: id
val onuStatesByBucket: Map<String, List<String>> get() {
val o = raw["ONU States"].obj ?: return emptyMap()
return o.mapValues { (_, v) -> v.array?.mapNotNull { it.string } ?: emptyList() }
}
val totalOnus: Int get() = onuStatesByBucket.values.sumOf { it.size }
val onlineOnus: Int get() = onuStatesByBucket.entries.sumOf { (bucket, ids) ->
if (OnuLifecycleState.fromBucket(bucket).isOnline) ids.size else 0
}
val populatedBuckets: List<Pair<OnuLifecycleState, List<String>>> get() = onuStatesByBucket
.filterValues { it.isNotEmpty() }
.map { OnuLifecycleState.fromBucket(it.key) to it.value.sorted() }
.sortedBy { it.first.ordinal }
private val olt get() = raw["OLT"]
private val ponStats get() = raw["STATS"]["OLT-PON"]
val onlineTime: String? get() = olt["Online Time"].string
val firmwareVersion: String? get() = olt["FW Version"].string
val model: String? get() = olt["Model"].string
val ponMode: String? get() = olt["PON Mode"].string
val statsTotalOnus: Int? get() = ponStats["Total ONUs Count"].int
val statsOnlineOnus: Int? get() = ponStats["Online ONUs Count"].int
val rxUtilPercent: Int? get() = ponStats["RX BW Total Util"].int
val txUtilPercent: Int? get() = ponStats["TX BW Total Util"].int
val rxRateBps: Double? get() = ponStats["RX BW Ethernet Rate bps"].double
val txRateBps: Double? get() = ponStats["TX BW Ethernet Rate bps"].double
val asicTempC: Int? get() = raw["STATS"]["OLT-TEMP"]["ASIC"].int
val voltage: Double? get() = raw["STATS"]["OLT-ENV"]["Voltage"].double
val laserShutdown: String? get() = olt["Laser Shutdown"].string
val isLaserOff: Boolean get() = laserShutdown?.let { !it.equals("Laser ON", ignoreCase = true) } ?: false
val switchName: String? get() = raw["Switch"]["System Name"].string?.takeIf { it.isNotEmpty() }
val switchPort: String? get() = raw["Switch"]["Port ID"].string?.takeIf { it.isNotEmpty() }
val switchAddress: String? get() = raw["Switch"]["IPv4 Address"].string?.takeIf { it.isNotEmpty() }
}
class OltConfigDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
/** Operator label `OLT.Name` (NOT `NETCONF.Name`, which defaults to the MAC). */
val name: String? get() = raw["OLT"]["Name"].string?.takeIf { it.isNotEmpty() }
}
class ControllerConfigDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
}
// ───────────────────────── Alarms / logs / stats / CPE ─────────────────────────
class AlarmHistoryDoc(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val severity: AlarmSeverity get() = AlarmSeverity.from(raw["Severity"].string ?: raw["severity"].string)
val message: String? get() = raw["Message"].string ?: raw["message"].string
val raisedAt: String? get() = raw["Time"].string ?: raw["timestamp"].string
}
class LogEntry(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val severity: AlarmSeverity get() = AlarmSeverity.from(raw["Severity"].string ?: raw["severity"].string)
val message: String? get() = raw["Message"].string ?: raw["message"].string
val time: String? get() = raw["Time"].string ?: raw["timestamp"].string
}
class StatSample(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val timestamp: String? get() = raw["Time"].string ?: raw["timestamp"].string
val numericMetrics: List<Pair<String, Double>> get() = (raw.obj ?: emptyMap())
.mapNotNull { (k, v) -> v.double?.let { k to it } }.sortedBy { it.first }
}
/** A CPE behind an ONU with its DHCP lease — from the `/cpe/onu/<id>/` helper. */
class CpeState(val raw: JsonElement) {
val id: String get() = raw["_id"].string ?: "?"
val mac: String? get() = raw["_id"].string
val dhcpState: String? get() = raw["DHCP"]["Client State"].string?.takeIf { it.isNotEmpty() }
val ipv4: String? get() = raw["DHCP"]["Client IP Addr"].string?.takeIf { it.isNotEmpty() }
val dhcpServer: String? get() = raw["DHCP"]["Server ID"].string?.takeIf { it.isNotEmpty() }
val dhcpLeaseSeconds: Int? get() = raw["DHCP"]["Lease Time"].int
val circuitId: String? get() = raw["DHCP"]["Circuit ID"].string?.takeIf { it.isNotEmpty() }
val dhcpLastSuccess: String? get() = raw["DHCP"]["Last Success Time"].string?.takeIf { it.isNotEmpty() }
val dhcpv6State: String? get() = raw["DHCPV6"]["Client State"].string?.takeIf { it.isNotEmpty() }
val ipv6: String? get() = raw["DHCPV6"]["Client IP Addr"].string?.takeIf { it.isNotEmpty() }
val ipv6ValidLifetime: String? get() = raw["DHCPV6"]["Valid Lifetime"].string?.takeIf { it.isNotEmpty() }
val ipv6Prefix: String? get() {
val entry = raw["DHCPV6"]["Prefix Delegation"].array?.firstOrNull() ?: return null
val prefixObj = entry["Prefixes"].array?.firstOrNull() ?: return null
val prefix = prefixObj["Prefix"].string ?: return null
val len = prefixObj["Prefix Length"].int ?: return null
return "$prefix/$len"
}
val hasLease: Boolean get() = ipv4 != null || ipv6 != null
}

View file

@ -0,0 +1,90 @@
package no.svorka.mcms.core
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
/**
* In-memory cookie jar (per host), so we can both replay cookies and read the
* CSRF cookie value. Sessions are discarded on logout/app-kill.
*/
class SessionCookieJar : CookieJar {
private val store = mutableMapOf<String, MutableList<Cookie>>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val list = store.getOrPut(url.host) { mutableListOf() }
cookies.forEach { c ->
list.removeAll { it.name == c.name }
list.add(c)
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
store[url.host]?.filter { it.matches(url) } ?: emptyList()
fun valueMatching(host: String, base: String): String? =
store[host]?.firstOrNull { matches(it.name, base) }?.value
fun hasCookie(host: String, base: String): Boolean =
store[host]?.any { matches(it.name, base) } == true
fun clear(host: String) { store.remove(host) }
// Modern MCMS builds emit `__Host-`/`__Secure-` prefixed cookies; accept either.
private fun matches(name: String, base: String) =
name == base || name == "__Host-$base" || name == "__Secure-$base"
}
/** Session state for one connection: the cookie jar + CSRF token access. */
class SessionStore(private val config: ApiConfiguration) {
val cookieJar = SessionCookieJar()
var isAuthenticated = false
private set
fun markAuthenticated() { isAuthenticated = true }
/** CSRF token to echo as the `X-CSRFToken` header. Null before login. */
fun csrfToken(): String? = config.host?.let { cookieJar.valueMatching(it, config.csrfCookieName) }
val hasSessionCookie: Boolean
get() = config.host?.let { cookieJar.hasCookie(it, config.sessionCookieName) } == true
fun clear() {
config.host?.let { cookieJar.clear(it) }
isAuthenticated = false
}
}
/** Applies a [ServerTrustPolicy] to an OkHttp client builder. */
object ServerTrust {
fun apply(builder: OkHttpClient.Builder, policy: ServerTrustPolicy) {
when (policy) {
is ServerTrustPolicy.System -> Unit // default platform trust
is ServerTrustPolicy.AllowSelfSignedForHost -> {
// Accept ANY certificate, but the hostname verifier restricts it to
// the one allowed host (matches the iOS self-signed escape hatch).
val trustAll = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val ctx = SSLContext.getInstance("TLS").apply {
init(null, arrayOf<javax.net.ssl.TrustManager>(trustAll), SecureRandom())
}
builder.sslSocketFactory(ctx.socketFactory, trustAll)
builder.hostnameVerifier { hostname, _ -> hostname.equals(policy.host, ignoreCase = true) }
}
is ServerTrustPolicy.PinPublicKeySha256 -> {
val pinner = okhttp3.CertificatePinner.Builder().apply {
policy.pins.forEach { add(policy.host, "sha256/$it") }
}.build()
builder.certificatePinner(pinner)
}
}
}
}

View file

@ -0,0 +1,132 @@
package no.svorka.mcms.core
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class AuthRepository(
private val client: ApiClient,
private val session: SessionStore,
private val config: ApiConfiguration,
) {
suspend fun login(email: String, password: String) {
val body = buildJsonObject { put("email", email); put("password", password) }
// Login body is unwrapped (matches the official 6.2 dev guide).
client.send("POST", McmsEndpoint.Authenticate, body, wrapInData = false)
session.markAuthenticated()
config.databaseId?.let { selectDatabase(it) }
}
suspend fun selectDatabase(id: String) {
// `{ "data": "<db id>" }` — a bare string.
client.send("PUT", McmsEndpoint.DatabaseSelection, JsonPrimitive(id), wrapInData = true)
}
suspend fun logout() {
runCatching { client.action("GET", McmsEndpoint.Logout) }
session.clear()
}
suspend fun version(): VersionInfo = VersionInfo(client.getElement(McmsEndpoint.Version))
}
class OnuRepository(private val client: ApiClient) {
suspend fun states(query: ApiQuery = ApiQuery.EMPTY): List<OnuStateDoc> =
client.getList(McmsEndpoint.OnuStates, query).map { OnuStateDoc(it) }
suspend fun allStates(): List<OnuStateDoc> =
client.getAll(McmsEndpoint.OnuStates) { it["_id"].string }.map { OnuStateDoc(it) }
suspend fun state(id: String): OnuStateDoc = OnuStateDoc(client.getElement(McmsEndpoint.OnuState(id)))
suspend fun config(id: String): OnuConfigDoc = OnuConfigDoc(client.getElement(McmsEndpoint.OnuConfig(id)))
/** id → operator name via projection (only `ONU.Name` comes back). */
suspend fun nameMap(): Map<String, String> {
val q = ApiQuery(projection = mapOf("ONU.Name" to 1))
return client.getAll(McmsEndpoint.OnuConfigs, baseQuery = q) { it["_id"].string }
.map { OnuConfigDoc(it) }
.mapNotNull { c -> c.name?.takeIf { it.isNotEmpty() }?.let { c.id to it } }
.toMap()
}
suspend fun stats(id: String, lastHours: Int = 24): List<StatSample> =
client.getList(McmsEndpoint.OnuStats(id), ApiQuery.window(lastHours)).map { StatSample(it) }
suspend fun logs(id: String, lastHours: Int = 24): List<LogEntry> =
client.getList(McmsEndpoint.OnuLogs(id), ApiQuery.window(lastHours)).map { LogEntry(it) }
suspend fun alarmHistories(query: ApiQuery = ApiQuery.EMPTY): List<AlarmHistoryDoc> =
client.getList(McmsEndpoint.OnuAlarmHistories, query).map { AlarmHistoryDoc(it) }
/** CPE DHCP state from the `/cpe/onu/<id>/` helper — a bare `[code, cpe…]` array. */
suspend fun cpes(id: String): List<CpeState> =
client.getRaw(McmsEndpoint.OnuCpe(id)).array?.filter { it.isObject }?.map { CpeState(it) } ?: emptyList()
suspend fun reset(id: String) { client.action("PUT", McmsEndpoint.OnuReset(id)) }
suspend fun firmwareFiles(): List<FirmwareFile> =
client.getList(McmsEndpoint.OnuFirmwareFiles).map { FirmwareFile(it) }
/** Procedure 7: stage firmware in the inactive bank. Returns the slot. Service-affecting. */
suspend fun upgradeFirmware(id: String, file: FirmwareFile): Int {
val filename = file.filename ?: throw ApiError.Encoding("Firmware file is missing a filename.")
val version = file.version ?: throw ApiError.Encoding("Firmware file is missing a version.")
val current = config(id)
val result = FirmwareUpgrade.apply(current.raw, filename, version)
?: throw ApiError.Encoding("Unexpected ONU-CFG shape — firmware write aborted.")
client.send("PUT", McmsEndpoint.OnuConfig(id), result.document, wrapInData = true)
return result.slot
}
}
class OltRepository(private val client: ApiClient) {
suspend fun states(query: ApiQuery = ApiQuery.EMPTY): List<OltStateDoc> =
client.getList(McmsEndpoint.OltStates, query).map { OltStateDoc(it) }
suspend fun allStates(): List<OltStateDoc> =
client.getAll(McmsEndpoint.OltStates) { it["_id"].string }.map { OltStateDoc(it) }
suspend fun state(id: String): OltStateDoc = OltStateDoc(client.getElement(McmsEndpoint.OltState(id)))
suspend fun allConfigs(): List<OltConfigDoc> =
client.getAll(McmsEndpoint.OltConfigs) { it["_id"].string }.map { OltConfigDoc(it) }
suspend fun nameMap(): Map<String, String> {
val q = ApiQuery(projection = mapOf("OLT.Name" to 1))
return client.getAll(McmsEndpoint.OltConfigs, baseQuery = q) { it["_id"].string }
.map { OltConfigDoc(it) }
.mapNotNull { c -> c.name?.takeIf { it.isNotEmpty() && it != c.id }?.let { c.id to it } }
.toMap()
}
suspend fun reset(id: String) { client.action("PUT", McmsEndpoint.OltReset(id)) }
suspend fun registrationMap(): Map<String, OnuLifecycleState> =
OltRepository.registrationMap(allStates())
companion object {
/** Invert OLT-STATE buckets → onuId: state; prefer non-Registered on duplicate (§3.11). */
fun registrationMap(olts: List<OltStateDoc>): Map<String, OnuLifecycleState> {
val map = mutableMapOf<String, OnuLifecycleState>()
for (olt in olts) {
for ((bucket, ids) in olt.onuStatesByBucket) {
val state = OnuLifecycleState.fromBucket(bucket)
for (id in ids) {
val existing = map[id]
when {
existing == null -> map[id] = state
existing == OnuLifecycleState.REGISTERED && state != OnuLifecycleState.REGISTERED -> map[id] = state
}
}
}
}
return map
}
}
}
class ControllerRepository(private val client: ApiClient) {
suspend fun allConfigs(): List<ControllerConfigDoc> =
client.getAll(McmsEndpoint.ControllerConfigs) { it["_id"].string }.map { ControllerConfigDoc(it) }
}

View file

@ -0,0 +1,70 @@
package no.svorka.mcms.core
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Parses/renders MCMS timestamps: UTC with a literal space, e.g.
* `2026-05-04 21:51:57.394127`. Try microsecond precision first, then seconds.
*/
object MongoTimestamp {
private val withMicros = utc("yyyy-MM-dd HH:mm:ss.SSSSSS")
private val seconds = utc("yyyy-MM-dd HH:mm:ss")
private val display = SimpleDateFormat("d MMM yyyy, HH:mm", Locale.getDefault())
private fun utc(pattern: String) = SimpleDateFormat(pattern, Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
isLenient = false
}
fun parse(s: String?): Date? {
if (s == null) return null
return runCatching { withMicros.parse(s) }.getOrNull()
?: runCatching { seconds.parse(s) }.getOrNull()
}
fun displayString(s: String?): String? {
if (s == null) return null
val d = parse(s) ?: return s
return display.format(d)
}
/** Uptime etc., computed from two MCMS timestamps so it uses the device clock. */
fun duration(from: String?, to: String?): String? {
val start = parse(from) ?: return null
val end = parse(to) ?: return null
val total = (end.time - start.time) / 1000
if (total < 0) return null
val days = total / 86400
val hours = (total % 86400) / 3600
val mins = (total % 3600) / 60
return when {
days > 0 -> "${days}d ${hours}h"
hours > 0 -> "${hours}h ${mins}m"
else -> "${mins}m"
}
}
}
/** Compact human formatting for counters in the UI. */
object Format {
fun bps(value: Double?): String {
if (value == null || value < 0) return ""
val units = listOf("bps", "kbps", "Mbps", "Gbps", "Tbps")
var x = value; var i = 0
while (x >= 1000 && i < units.size - 1) { x /= 1000; i++ }
return if (i == 0 || x >= 100) "${x.toLong()} ${units[i]}" else "${"%.1f".format(x)} ${units[i]}"
}
fun bytes(value: Double?): String {
if (value == null || value < 0) return ""
val units = listOf("B", "KB", "MB", "GB", "TB", "PB")
var x = value; var i = 0
while (x >= 1024 && i < units.size - 1) { x /= 1024; i++ }
return if (i == 0 || x >= 100) "${x.toLong()} ${units[i]}" else "${"%.1f".format(x)} ${units[i]}"
}
fun percent(value: Int?): String = value?.let { "$it%" } ?: ""
}

View file

@ -0,0 +1,83 @@
package no.svorka.mcms.ui
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import no.svorka.mcms.core.ApiConfiguration
import no.svorka.mcms.core.ApiError
import no.svorka.mcms.core.KeychainStore
import no.svorka.mcms.core.MCMSJson
import no.svorka.mcms.core.McmsConnection
/**
* Top-level app state: owns the active [McmsConnection], persists the (non-secret)
* [ApiConfiguration] to SharedPreferences, and keeps credentials in the Keystore.
*/
class AppState(context: Context) {
private val prefs = context.getSharedPreferences("mcms.app", Context.MODE_PRIVATE)
private val keychain = KeychainStore(context)
var configuration by mutableStateOf<ApiConfiguration?>(null)
private set
var connection by mutableStateOf<McmsConnection?>(null)
private set
var isAuthenticated by mutableStateOf(false)
val isConfigured: Boolean get() = configuration != null
init { loadConfiguration() }
fun configure(config: ApiConfiguration) {
configuration = config
connection = makeConnection(config)
isAuthenticated = false
prefs.edit().putString("config", MCMSJson.encodeToString(ApiConfiguration.serializer(), config)).apply()
}
private fun makeConnection(config: ApiConfiguration): McmsConnection {
val conn = McmsConnection(config)
conn.onAuthExpired = { isAuthenticated = false }
return conn
}
private fun loadConfiguration() {
val json = prefs.getString("config", null) ?: return
runCatching { MCMSJson.decodeFromString(ApiConfiguration.serializer(), json) }.getOrNull()?.let {
configuration = it
connection = makeConnection(it)
}
}
val savedEmail: String? get() = keychain.get("email")
val savedPassword: String? get() = keychain.get("password")
suspend fun signIn(email: String, password: String, remember: Boolean) {
val conn = connection ?: throw ApiError.Transport("No connection configured")
conn.auth.login(email, password)
if (remember) {
keychain.set("email", email)
keychain.set("password", password)
}
isAuthenticated = true
}
suspend fun signOut() {
connection?.auth?.logout()
isAuthenticated = false
}
/** Reachability probe: a 401/403/404 means the host answered, which is success. */
suspend fun probe(config: ApiConfiguration): String {
val conn = McmsConnection(config)
return try {
val version = conn.auth.version()
version.displayVersion?.let { "PON Manager $it" } ?: "Reachable"
} catch (e: ApiError) {
when (e) {
is ApiError.Unauthorized, is ApiError.Forbidden, ApiError.NotFound -> "Reachable — sign in to continue"
else -> throw e
}
}
}
}

View file

@ -0,0 +1,171 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import no.svorka.mcms.core.ApiConfiguration
import no.svorka.mcms.core.ServerTrustPolicy
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(app: AppState, onOpenSettings: () -> Unit) {
var email by rememberSaveable { mutableStateOf(app.savedEmail ?: "") }
var password by rememberSaveable { mutableStateOf(app.savedPassword ?: "") }
var remember by rememberSaveable { mutableStateOf(true) }
var phase by remember { mutableStateOf<LoadPhase>(LoadPhase.Idle) }
val scope = rememberCoroutineScope()
Scaffold(topBar = {
TopAppBar(
title = { Text("PON Go") },
actions = { IconButton(onClick = onOpenSettings) { Icon(Icons.Filled.Settings, contentDescription = "Settings") } },
)
}) { padding ->
Column(Modifier.padding(padding).padding(16.dp).fillMaxSize()) {
OutlinedTextField(
value = email, onValueChange = { email = it }, label = { Text("Email") }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password, onValueChange = { password = it }, label = { Text("Password") }, singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = remember, onCheckedChange = { remember = it })
Text("Remember me")
}
app.configuration?.host?.let {
Text("Connecting to $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.height(16.dp))
Button(
onClick = {
phase = LoadPhase.Loading
scope.launch {
phase = try { app.signIn(email, password, remember); LoadPhase.Loaded }
catch (e: Throwable) { LoadPhase.Failed(e.uiMessage()) }
}
},
enabled = email.isNotBlank() && password.isNotBlank() && phase != LoadPhase.Loading,
modifier = Modifier.fillMaxWidth(),
) {
if (phase == LoadPhase.Loading) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
else Text("Sign in")
}
(phase as? LoadPhase.Failed)?.let {
Spacer(Modifier.height(8.dp))
Text(it.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) {
var url by rememberSaveable { mutableStateOf(app.configuration?.baseUrl ?: "https://") }
var apiVersion by rememberSaveable { mutableStateOf(app.configuration?.apiVersion ?: "v1") }
var dbId by rememberSaveable { mutableStateOf(app.configuration?.databaseId ?: "") }
var allowSelfSigned by rememberSaveable {
mutableStateOf(app.configuration?.trustPolicy is ServerTrustPolicy.AllowSelfSignedForHost)
}
var testPhase by remember { mutableStateOf<LoadPhase>(LoadPhase.Idle) }
var testResult by remember { mutableStateOf<String?>(null) }
var saveError by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
fun makeConfig(): ApiConfiguration? {
val base = ApiConfiguration.normalizedBaseUrl(url) ?: return null
val host = ApiConfiguration(baseUrl = base).host
val policy = if (allowSelfSigned && host != null) ServerTrustPolicy.AllowSelfSignedForHost(host)
else ServerTrustPolicy.System
return ApiConfiguration(
baseUrl = base, apiVersion = apiVersion.trim(),
databaseId = dbId.ifBlank { null }, trustPolicy = policy,
)
}
val canSave = ApiConfiguration.normalizedBaseUrl(url) != null && apiVersion.isNotBlank()
Scaffold(topBar = {
TopAppBar(
title = { Text(if (initialSetup) "Set up connection" else "Settings") },
navigationIcon = { if (!initialSetup) TextButton(onClick = onClose) { Text("Close") } },
actions = {
TextButton(
enabled = canSave,
onClick = {
val config = makeConfig()
if (config == null) { saveError = "Enter a valid URL including scheme (https://)." }
else { saveError = null; app.configure(config); if (!initialSetup) onClose() }
},
) { Text("Save") }
},
)
}) { padding ->
Column(
Modifier.padding(padding).padding(16.dp).fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = url, onValueChange = { url = it }, label = { Text("Server URL") }, singleLine = true,
supportingText = { Text("Host or origin, e.g. https://mcms.lab.svorka.net — “/api” is appended automatically.") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = apiVersion, onValueChange = { apiVersion = it }, label = { Text("API version") }, singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = dbId, onValueChange = { dbId = it }, label = { Text("Database ID (optional)") }, singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = allowSelfSigned, onCheckedChange = { allowSelfSigned = it })
Spacer(Modifier.width(8.dp))
Text("Allow self-signed certificate")
}
Button(
enabled = canSave && testPhase != LoadPhase.Loading,
onClick = {
val config = makeConfig() ?: return@Button
testPhase = LoadPhase.Loading; testResult = null
scope.launch {
try { testResult = app.probe(config); testPhase = LoadPhase.Loaded }
catch (e: Throwable) { testResult = e.uiMessage(); testPhase = LoadPhase.Failed(e.uiMessage()) }
}
},
) {
if (testPhase == LoadPhase.Loading) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
else Text("Test connection")
}
testResult?.let {
val isErr = testPhase is LoadPhase.Failed
Text(it, color = if (isErr) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall)
}
saveError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (!initialSetup) {
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = { scope.launch { app.signOut() } }) { Text("Sign out") }
}
}
}
}

View file

@ -0,0 +1,118 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun StateBadge(text: String, color: Color) {
Surface(color = color.copy(alpha = 0.18f), shape = RoundedCornerShape(50)) {
Text(
text,
color = color,
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
@Composable
fun LoadingView(label: String = "Loading…") {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
fun ErrorState(message: String, onRetry: (() -> Unit)? = null) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Filled.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
Text("Something went wrong", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Text(message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (onRetry != null) {
Spacer(Modifier.height(16.dp))
Button(onClick = onRetry) { Text("Retry") }
}
}
}
}
@Composable
fun CountTile(title: String, value: Int, modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Column(Modifier.padding(14.dp)) {
Text(title, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(6.dp))
Text("$value", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
}
}
}
@Composable
fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Column {
Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp))
Card { Column(Modifier.padding(14.dp), content = content) }
}
}
@Composable
fun KeyValueRow(label: String, value: String) {
Row(
Modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
fun BackButton(onBack: () -> Unit) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }
}
@Composable
fun ListRow(onClick: (() -> Unit)? = null, content: @Composable RowScope.() -> Unit) {
Row(
Modifier.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}

View file

@ -0,0 +1,232 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import no.svorka.mcms.core.*
class DashboardModel(private val connection: McmsConnection) {
var phase by mutableStateOf<LoadPhase>(LoadPhase.Idle)
var onuTotal by mutableStateOf(0)
var oltCount by mutableStateOf(0)
var controllerCount by mutableStateOf(0)
var onuCounts by mutableStateOf<List<Pair<OnuLifecycleState, Int>>>(emptyList())
var severityCounts by mutableStateOf<List<Pair<AlarmSeverity, Int>>>(emptyList())
var totalDownBps by mutableStateOf(0.0)
var totalUpBps by mutableStateOf(0.0)
var abnormalRx by mutableStateOf<List<OnuStateDoc>>(emptyList())
var lasersOff by mutableStateOf<List<OltStateDoc>>(emptyList())
var opticalAvailable by mutableStateOf(false)
suspend fun load(extraStats: Boolean) {
phase = LoadPhase.Loading
try {
val olts = connection.olt.allStates()
oltCount = olts.size
val reg = OltRepository.registrationMap(olts)
onuTotal = reg.size
onuCounts = reg.values.groupingBy { it }.eachCount().toList().sortedByDescending { it.second }
totalDownBps = olts.mapNotNull { it.txRateBps }.sum()
totalUpBps = olts.mapNotNull { it.rxRateBps }.sum()
lasersOff = olts.filter { it.isLaserOff }
controllerCount = runCatching { connection.controller.allConfigs().size }.getOrDefault(0)
val alarms = runCatching { connection.onu.alarmHistories() }.getOrDefault(emptyList())
severityCounts = alarms.groupingBy { it.severity }.eachCount().toList().sortedBy { it.first.rank }
if (extraStats) {
val onus = connection.onu.allStates()
opticalAvailable = onus.any { it.rxOpticalDBm != null }
abnormalRx = onus.filter { val rx = it.rxOpticalDBm; rx != null && (rx < -28 || rx > -10) }
} else {
opticalAvailable = false; abnormalRx = emptyList()
}
phase = LoadPhase.Loaded
} catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage())
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
connection: McmsConnection,
openAbnormalRx: (List<OnuStateDoc>) -> Unit,
openLaserOff: (List<OltStateDoc>) -> Unit,
) {
val model = remember(connection) { DashboardModel(connection) }
var extraStats by rememberSaveable { mutableStateOf(false) }
var autoRefresh by rememberSaveable { mutableStateOf(false) }
var menuOpen by remember { mutableStateOf(false) }
LaunchedEffect(extraStats, autoRefresh) {
while (true) {
model.load(extraStats)
if (!autoRefresh) break
delay(30_000)
}
}
Scaffold(topBar = {
TopAppBar(
title = { Text("Dashboard") },
actions = {
IconButton(onClick = { menuOpen = true }) { Icon(Icons.Filled.MoreVert, "Options") }
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("Detailed stats (Rx scan)") },
trailingIcon = { Switch(checked = extraStats, onCheckedChange = { extraStats = it }) },
onClick = { extraStats = !extraStats },
)
DropdownMenuItem(
text = { Text("Auto-refresh · 30s") },
trailingIcon = { Switch(checked = autoRefresh, onCheckedChange = { autoRefresh = it }) },
onClick = { autoRefresh = !autoRefresh },
)
}
},
)
}) { padding ->
Box(Modifier.padding(padding).fillMaxSize()) {
when (val p = model.phase) {
is LoadPhase.Idle, is LoadPhase.Loading -> if (model.onuTotal == 0 && model.oltCount == 0) LoadingView("Loading network…") else content(model, openAbnormalRx, openLaserOff)
is LoadPhase.Failed -> ErrorState(p.message)
is LoadPhase.Loaded -> content(model, openAbnormalRx, openLaserOff)
}
}
}
}
@Composable
private fun content(
model: DashboardModel,
openAbnormalRx: (List<OnuStateDoc>) -> Unit,
openLaserOff: (List<OltStateDoc>) -> Unit,
) {
LazyColumn(
Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
CountTile("ONUs", model.onuTotal, Modifier.weight(1f))
CountTile("OLTs", model.oltCount, Modifier.weight(1f))
CountTile("Controllers", model.controllerCount, Modifier.weight(1f))
}
}
if (model.totalDownBps > 0 || model.totalUpBps > 0) {
item {
SectionCard("Traffic · all OLTs") {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text("↓ Downstream", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(Format.bps(model.totalDownBps), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
Column(horizontalAlignment = Alignment.End) {
Text("↑ Upstream", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(Format.bps(model.totalUpBps), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
}
}
}
}
if (model.opticalAvailable || model.lasersOff.isNotEmpty()) {
item {
SectionCard("Health") {
if (model.opticalAvailable) {
HealthRow("ONUs with abnormal Rx", "< 28 or > 10 dBm", model.abnormalRx.size) {
if (model.abnormalRx.isNotEmpty()) openAbnormalRx(model.abnormalRx)
}
}
HealthRow("OLTs with laser off", null, model.lasersOff.size) {
if (model.lasersOff.isNotEmpty()) openLaserOff(model.lasersOff)
}
}
}
}
if (model.onuCounts.isNotEmpty()) {
item {
SectionCard("ONU states") {
model.onuCounts.forEach { (state, count) ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
StateBadge(state.label, state.color())
Spacer(Modifier.weight(1f))
Text("$count")
}
}
}
}
}
}
}
@Composable
private fun HealthRow(title: String, detail: String?, count: Int, onClick: () -> Unit) {
Row(
Modifier.fillMaxWidth().clickable(enabled = count > 0, onClick = onClick).padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(title)
if (detail != null) Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text("$count", fontWeight = FontWeight.SemiBold, color = if (count > 0) Color(0xFFEF6C00) else Color(0xFF2E7D32))
if (count > 0) Icon(Icons.Filled.ChevronRight, null, tint = MaterialTheme.colorScheme.outline)
}
}
// ── Drill-down screens ──
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AbnormalRxScreen(onus: List<OnuStateDoc>, onBack: () -> Unit, openOnu: (String) -> Unit) {
Scaffold(topBar = { TopAppBar(title = { Text("Abnormal Rx") }, navigationIcon = { BackButton(onBack) }) }) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
items(onus) { onu ->
ListRow(onClick = { openOnu(onu.id) }) {
Column(Modifier.weight(1f)) {
Text(onu.displayName, fontWeight = FontWeight.Medium)
onu.parentOlt?.let { Text("OLT $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
onu.rxOpticalDBm?.let { rx ->
Column(horizontalAlignment = Alignment.End) {
Text("${"%.1f".format(rx)} dBm", color = Color(0xFFEF6C00))
Text(if (rx < -28) "weak" else "strong", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
HorizontalDivider()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LaserOffScreen(olts: List<OltStateDoc>, onBack: () -> Unit, openOlt: (String, String) -> Unit) {
Scaffold(topBar = { TopAppBar(title = { Text("Laser off") }, navigationIcon = { BackButton(onBack) }) }) { padding ->
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
items(olts) { olt ->
ListRow(onClick = { openOlt(olt.id, olt.displayName) }) {
Column(Modifier.weight(1f)) {
Text(olt.displayName, fontWeight = FontWeight.Medium)
Text(olt.laserShutdown ?: "Laser off", style = MaterialTheme.typography.bodySmall, color = Color(0xFFEF6C00))
}
}
HorizontalDivider()
}
}
}
}

View file

@ -0,0 +1,202 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import no.svorka.mcms.core.*
class OltListModel(private val connection: McmsConnection) {
var phase by mutableStateOf<LoadPhase>(LoadPhase.Idle)
var olts by mutableStateOf<List<OltStateDoc>>(emptyList())
var search by mutableStateOf("")
val filtered: List<OltStateDoc>
get() {
val q = search.trim()
if (q.isEmpty()) return olts
return olts.filter { it.id.contains(q, true) || (it.resolvedName?.contains(q, true) == true) }
}
suspend fun load() {
phase = LoadPhase.Loading
try {
val fetched = connection.olt.allStates()
val names = runCatching { connection.olt.nameMap() }.getOrDefault(emptyMap())
fetched.forEach { it.resolvedName = names[it.id] }
olts = fetched.sortedBy { it.displayName.lowercase() }
phase = LoadPhase.Loaded
} catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage())
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OltListScreen(connection: McmsConnection, openOlt: (String, String) -> Unit) {
val model = remember(connection) { OltListModel(connection) }
LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() }
Scaffold(topBar = { TopAppBar(title = { Text("OLTs") }) }) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
OutlinedTextField(
value = model.search, onValueChange = { model.search = it },
placeholder = { Text("OLT name or MAC") }, singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
)
when (val p = model.phase) {
is LoadPhase.Idle, is LoadPhase.Loading -> if (model.olts.isEmpty()) LoadingView("Loading OLTs…") else OltList(model.filtered, openOlt)
is LoadPhase.Failed -> ErrorState(p.message)
is LoadPhase.Loaded -> OltList(model.filtered, openOlt)
}
}
}
}
@Composable
private fun OltList(olts: List<OltStateDoc>, openOlt: (String, String) -> Unit) {
LazyColumn(Modifier.fillMaxSize()) {
items(olts) { olt ->
ListRow(onClick = { openOlt(olt.id, olt.displayName) }) {
Column(Modifier.weight(1f)) {
Text(olt.displayName, fontWeight = FontWeight.Medium)
Text("${olt.totalOnus} ONUs · ${olt.onlineOnus} online", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
val down = olt.totalOnus - olt.onlineOnus
when {
olt.totalOnus == 0 -> StateBadge("no ONUs", Color(0xFF9E9E9E))
down > 0 -> StateBadge("$down down", Color(0xFFEF6C00))
else -> StateBadge("all up", Color(0xFF2E7D32))
}
}
HorizontalDivider()
}
}
}
// ───────────────────────── Detail ─────────────────────────
class OltDetailModel(private val connection: McmsConnection, val oltId: String) {
var phase by mutableStateOf<LoadPhase>(LoadPhase.Idle)
var olt by mutableStateOf<OltStateDoc?>(null)
var actionMessage by mutableStateOf<String?>(null)
var busy by mutableStateOf(false)
suspend fun load() {
phase = LoadPhase.Loading
try { olt = connection.olt.state(oltId); phase = LoadPhase.Loaded }
catch (e: Throwable) { phase = LoadPhase.Failed(e.uiMessage()) }
}
suspend fun reset() {
busy = true; actionMessage = null
try { connection.olt.reset(oltId); actionMessage = "Reset requested." } catch (e: Throwable) { actionMessage = e.uiMessage() }
busy = false
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OltDetailScreen(connection: McmsConnection, oltId: String, title: String, onBack: () -> Unit, openOnu: (String) -> Unit) {
val model = remember(oltId) { OltDetailModel(connection, oltId) }
val scope = rememberCoroutineScope()
var showReset by remember { mutableStateOf(false) }
var menu by remember { mutableStateOf(false) }
LaunchedEffect(oltId) { model.load() }
Scaffold(topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = { BackButton(onBack) },
actions = {
IconButton(onClick = { menu = true }) { Icon(Icons.Filled.MoreVert, "Actions") }
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(text = { Text("Reset OLT", color = MaterialTheme.colorScheme.error) },
enabled = !model.busy, onClick = { menu = false; showReset = true })
}
},
)
}) { padding ->
Box(Modifier.padding(padding).fillMaxSize()) {
when (val p = model.phase) {
is LoadPhase.Idle, is LoadPhase.Loading -> if (model.olt == null) LoadingView() else OltContent(model.olt!!, openOnu)
is LoadPhase.Failed -> ErrorState(p.message) { scope.launch { model.load() } }
is LoadPhase.Loaded -> model.olt?.let { OltContent(it, openOnu) }
}
}
}
model.actionMessage?.let { msg ->
AlertDialog(onDismissRequest = { model.actionMessage = null },
confirmButton = { TextButton(onClick = { model.actionMessage = null }) { Text("OK") } },
title = { Text("Operation") }, text = { Text(msg) })
}
if (showReset) ResetConfirmDialog("OLT", title, onDismiss = { showReset = false }) { scope.launch { model.reset() } }
}
@Composable
private fun OltContent(olt: OltStateDoc, openOnu: (String) -> Unit) {
LazyColumn(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
item {
SectionCard("Status") {
val down = olt.totalOnus - olt.onlineOnus
KeyValueRow("ONUs", "${olt.totalOnus} · ${olt.onlineOnus} online" + if (down > 0) " · $down down" else "")
olt.model?.let { KeyValueRow("Model", it) }
olt.ponMode?.let { KeyValueRow("PON mode", it) }
olt.firmwareVersion?.let { KeyValueRow("Firmware", it) }
olt.onlineTime?.let {
MongoTimestamp.duration(it, olt.lastUpdate)?.let { up -> KeyValueRow("Uptime", up) }
KeyValueRow("Online since", MongoTimestamp.displayString(it) ?: it)
}
}
}
if (olt.rxRateBps != null || olt.txRateBps != null) {
item {
SectionCard("PON traffic") {
KeyValueRow("Downstream", "${Format.bps(olt.txRateBps)} · ${Format.percent(olt.txUtilPercent)} util")
KeyValueRow("Upstream", "${Format.bps(olt.rxRateBps)} · ${Format.percent(olt.rxUtilPercent)} util")
}
}
}
if (olt.asicTempC != null || olt.voltage != null) {
item {
SectionCard("Environment") {
olt.asicTempC?.let { KeyValueRow("ASIC temp", "$it °C") }
olt.voltage?.let { KeyValueRow("Voltage", "%.2f V".format(it)) }
}
}
}
olt.switchName?.let { sw ->
item {
SectionCard("Uplink") {
KeyValueRow("Switch", sw)
olt.switchPort?.let { KeyValueRow("Port", it) }
olt.switchAddress?.let { KeyValueRow("Address", it) }
}
}
}
olt.populatedBuckets.forEach { (state, ids) ->
item {
Row(verticalAlignment = Alignment.CenterVertically) {
StateBadge(state.label, state.color())
Spacer(Modifier.weight(1f))
Text("${ids.size}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
items(ids) { onuId ->
ListRow(onClick = { openOnu(onuId) }) { Text(onuId) }
HorizontalDivider()
}
}
}
}

View file

@ -0,0 +1,453 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import no.svorka.mcms.core.*
// ───────────────────────── List ─────────────────────────
class OnuListModel(private val connection: McmsConnection) {
var phase by mutableStateOf<LoadPhase>(LoadPhase.Idle)
var onus by mutableStateOf<List<OnuStateDoc>>(emptyList())
var search by mutableStateOf("")
val filtered: List<OnuStateDoc>
get() {
val q = search.trim()
if (q.isEmpty()) return onus
return onus.filter {
it.id.contains(q, true) ||
(it.resolvedName?.contains(q, true) == true) ||
(it.parentOlt?.contains(q, true) == true)
}
}
suspend fun load() {
phase = LoadPhase.Loading
try {
val fetched = connection.onu.allStates()
val reg = runCatching { connection.olt.registrationMap() }.getOrDefault(emptyMap())
val names = runCatching { connection.onu.nameMap() }.getOrDefault(emptyMap())
fetched.forEach { it.resolvedLifecycle = reg[it.id]; it.resolvedName = names[it.id] }
onus = fetched.sortedBy { it.displayName.lowercase() }
phase = LoadPhase.Loaded
} catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage())
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OnuListScreen(connection: McmsConnection, openOnu: (String) -> Unit) {
val model = remember(connection) { OnuListModel(connection) }
LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() }
Scaffold(topBar = { TopAppBar(title = { Text("ONUs") }) }) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
OutlinedTextField(
value = model.search, onValueChange = { model.search = it },
placeholder = { Text("Name, serial, or OLT") }, singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
)
when (val p = model.phase) {
is LoadPhase.Idle, is LoadPhase.Loading -> if (model.onus.isEmpty()) LoadingView("Loading ONUs…") else OnuList(model.filtered, openOnu)
is LoadPhase.Failed -> ErrorState(p.message) { /* retry */ }
is LoadPhase.Loaded -> OnuList(model.filtered, openOnu)
}
}
}
}
@Composable
private fun OnuList(onus: List<OnuStateDoc>, openOnu: (String) -> Unit) {
LazyColumn(Modifier.fillMaxSize()) {
items(onus) { onu ->
ListRow(onClick = { openOnu(onu.id) }) {
Column(Modifier.weight(1f)) {
Text(onu.displayName, fontWeight = FontWeight.Medium)
subtitle(onu)?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
StateBadge(onu.lifecycle.label, onu.lifecycle.color())
}
HorizontalDivider()
}
}
}
private fun subtitle(onu: OnuStateDoc): String? {
val parts = buildList {
if (onu.resolvedName != null) add(onu.id)
onu.parentOlt?.let { add("OLT $it") }
}
return parts.joinToString(" · ").ifEmpty { null }
}
// ───────────────────────── Detail ─────────────────────────
private val prettyJson = Json { prettyPrint = true }
class OnuDetailModel(private val connection: McmsConnection, val onuId: String) {
var tab by mutableStateOf(0) // 0 overview · 1 cpe · 2 logs · 3 config
var phase by mutableStateOf<LoadPhase>(LoadPhase.Idle)
var liveState by mutableStateOf<OnuStateDoc?>(null)
var cpes by mutableStateOf<List<CpeState>>(emptyList())
var logs by mutableStateOf<List<LogEntry>>(emptyList())
var config by mutableStateOf<OnuConfigDoc?>(null)
var firmwareFiles by mutableStateOf<List<FirmwareFile>>(emptyList())
var firmwareError by mutableStateOf<String?>(null)
var actionMessage by mutableStateOf<String?>(null)
var busy by mutableStateOf(false)
suspend fun loadTab() { phase = LoadPhase.Loading; reload() }
suspend fun refresh() { reload() }
private suspend fun reload() {
try {
when (tab) {
0 -> liveState = connection.onu.state(onuId)
1 -> cpes = connection.onu.cpes(onuId)
2 -> logs = connection.onu.logs(onuId)
3 -> config = connection.onu.config(onuId)
}
phase = LoadPhase.Loaded
} catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage())
}
}
suspend fun loadFirmware() {
firmwareError = null
try { firmwareFiles = connection.onu.firmwareFiles() } catch (e: Throwable) { firmwareError = e.uiMessage() }
}
suspend fun reset() {
busy = true; actionMessage = null
try { connection.onu.reset(onuId); actionMessage = "Reset requested." } catch (e: Throwable) { actionMessage = e.uiMessage() }
busy = false
}
suspend fun upgrade(file: FirmwareFile) {
busy = true; actionMessage = null
try {
val slot = connection.onu.upgradeFirmware(onuId, file)
actionMessage = "Firmware ${file.version ?: "image"} staged to bank $slot. The ONU starts it after its next reboot."
} catch (e: Throwable) { actionMessage = e.uiMessage() }
busy = false
}
}
private val onuTabs = listOf("Overview", "CPE", "Logs", "Config")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OnuDetailScreen(connection: McmsConnection, onuId: String, onBack: () -> Unit) {
val model = remember(onuId) { OnuDetailModel(connection, onuId) }
val scope = rememberCoroutineScope()
var showReset by remember { mutableStateOf(false) }
var showFirmware by remember { mutableStateOf(false) }
var menu by remember { mutableStateOf(false) }
LaunchedEffect(model.tab) { model.loadTab() }
Scaffold(topBar = {
TopAppBar(
title = { Text(onuId) },
navigationIcon = { BackButton(onBack) },
actions = {
IconButton(onClick = { menu = true }) { Icon(Icons.Filled.MoreVert, "Actions") }
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(text = { Text("Reset ONU", color = MaterialTheme.colorScheme.error) },
enabled = !model.busy, onClick = { menu = false; showReset = true })
}
},
)
}) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = model.tab) {
onuTabs.forEachIndexed { i, title ->
Tab(selected = model.tab == i, onClick = { model.tab = i }, text = { Text(title) })
}
}
Box(Modifier.fillMaxSize()) {
when (val p = model.phase) {
is LoadPhase.Loading, is LoadPhase.Idle -> LoadingView()
is LoadPhase.Failed -> ErrorState(p.message) { scope.launch { model.loadTab() } }
is LoadPhase.Loaded -> when (model.tab) {
0 -> OverviewTab(model) { showFirmware = true }
1 -> CpeTab(model.cpes)
2 -> LogsTab(model.logs)
else -> ConfigTab(model.config)
}
}
}
}
}
model.actionMessage?.let { msg ->
AlertDialog(
onDismissRequest = { model.actionMessage = null },
confirmButton = { TextButton(onClick = { model.actionMessage = null }) { Text("OK") } },
title = { Text("Operation") }, text = { Text(msg) },
)
}
if (showReset) ResetConfirmDialog("ONU", onuId, onDismiss = { showReset = false }) { scope.launch { model.reset() } }
if (showFirmware) FirmwareDialog(model, onDismiss = { showFirmware = false })
}
@Composable
private fun OverviewTab(model: OnuDetailModel, onUpdateFirmware: () -> Unit) {
val s = model.liveState ?: return
LazyColumn(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
item {
SectionCard("Status") {
s.registrationState?.let { KeyValueRow("Registration", it) }
s.equipmentId?.let { KeyValueRow("Equipment", it) }
s.ponMode?.let { KeyValueRow("PON mode", it) }
s.temperatureC?.let { KeyValueRow("Temperature", "%.1f °C".format(it)) }
s.onlineTime?.let {
KeyValueRow("Online since", MongoTimestamp.displayString(it) ?: it)
MongoTimestamp.duration(it, s.lastUpdate)?.let { up -> KeyValueRow("Uptime", up) }
}
}
}
item {
SectionCard("Firmware") {
s.fwVersion?.let { KeyValueRow("Active version", it) }
s.fwBankVersions.forEachIndexed { i, v ->
KeyValueRow("Bank $i" + if (s.fwBankPtr == i) " · active" else "", v.ifEmpty { "" })
}
Spacer(Modifier.height(8.dp))
Button(onClick = onUpdateFirmware, enabled = !model.busy) { Text("Update firmware…") }
}
}
item {
SectionCard("Live optical") {
OpticalRow("RX optical", s.rxOpticalDBm, green = -28.0, warn = -30.0)
OpticalRow("TX optical", s.txOpticalDBm, green = 3.0, warn = 3.0)
FecRow("ONU post-FEC", s.onuPostFec, isPost = true)
FecRow("OLT post-FEC", s.oltPostFec, isPost = true)
}
}
if (s.uniPorts.isNotEmpty()) {
item {
SectionCard("UNI ports") {
s.uniPorts.forEach { port ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(port.name)
(port.state ?: listOfNotNull(port.speed, port.duplex).joinToString(" · ").ifEmpty { null })
?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
port.enabled?.let { StateBadge(if (it) "enabled" else "disabled", if (it) Color(0xFF2E7D32) else Color(0xFF757575)) }
}
}
}
}
}
if (s.ponServices.isNotEmpty()) {
item {
SectionCard("Traffic") {
s.ponServices.forEach { svc ->
Text(svc.name, fontWeight = FontWeight.Medium)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${Format.bps(svc.rxRateBps)}")
Text("${Format.bps(svc.txRateBps)}")
}
Spacer(Modifier.height(6.dp))
}
}
}
}
}
}
@Composable
private fun OpticalRow(title: String, value: Double?, green: Double, warn: Double) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(title)
val color = when {
value == null -> MaterialTheme.colorScheme.onSurfaceVariant
value >= green -> Color(0xFF2E7D32)
value >= warn -> Color(0xFFF9A825)
else -> Color(0xFFC62828)
}
Text(value?.let { "%.2f dBm".format(it) } ?: "", color = color)
}
}
@Composable
private fun FecRow(title: String, value: Double?, isPost: Boolean) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(title)
val color = when {
value == null -> MaterialTheme.colorScheme.onSurfaceVariant
isPost && value > 0 -> Color(0xFFC62828)
value > 0 -> MaterialTheme.colorScheme.onSurfaceVariant
else -> Color(0xFF2E7D32)
}
Text(value?.let { it.toLong().toString() } ?: "", color = color)
}
}
@Composable
private fun CpeTab(cpes: List<CpeState>) {
if (cpes.isEmpty()) { EmptyState("No CPEs"); return }
LazyColumn(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
items(cpes) { cpe ->
SectionCard(cpe.mac ?: cpe.id) {
cpe.ipv4?.let {
KeyValueRow("IPv4", it + (cpe.dhcpState?.let { s -> " ($s)" } ?: ""))
cpe.dhcpServer?.let { s -> KeyValueRow("DHCP server", s) }
cpe.dhcpLeaseSeconds?.let { l -> KeyValueRow("Lease", "${l / 3600}h") }
}
cpe.ipv6?.let {
KeyValueRow("IPv6", it)
cpe.ipv6Prefix?.let { p -> KeyValueRow("Delegated prefix", p) }
}
cpe.circuitId?.let { KeyValueRow("Circuit ID", it) }
if (!cpe.hasLease) Text("No active lease.", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun LogsTab(logs: List<LogEntry>) {
if (logs.isEmpty()) { EmptyState("No logs"); return }
LazyColumn(Modifier.fillMaxSize()) {
items(logs) { log ->
Column(Modifier.fillMaxWidth().padding(16.dp, 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
StateBadge(log.severity.label, log.severity.color())
Spacer(Modifier.weight(1f))
MongoTimestamp.displayString(log.time)?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
Text(log.message ?: log.id, style = MaterialTheme.typography.bodySmall)
}
HorizontalDivider()
}
}
}
@Composable
private fun ConfigTab(config: OnuConfigDoc?) {
if (config == null) { EmptyState("No configuration"); return }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
config.name?.let { KeyValueRow("Name", it); Spacer(Modifier.height(8.dp)) }
SelectionContainer {
Text(prettyJson.encodeToString(JsonElement.serializer(), config.raw),
fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun EmptyState(text: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
// ───────────────────────── Reset + Firmware dialogs ─────────────────────────
@Composable
fun ResetConfirmDialog(kind: String, name: String, onDismiss: () -> Unit, onConfirm: () -> Unit) {
var ack by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Reset $kind?") },
text = {
Column {
Text("Resetting $kind$name” reboots it and interrupts service for everything connected to it. This can't be undone.")
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = ack, onCheckedChange = { ack = it })
Text("I understand this will interrupt service")
}
}
},
confirmButton = {
TextButton(enabled = ack, onClick = { onConfirm(); onDismiss() }) {
Text("Reset $kind", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FirmwareDialog(model: OnuDetailModel, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
var showAll by remember { mutableStateOf(false) }
var pending by remember { mutableStateOf<FirmwareFile?>(null) }
val state = model.liveState
LaunchedEffect(Unit) { if (model.firmwareFiles.isEmpty()) model.loadFirmware() }
val compatible = model.firmwareFiles.filter { it.isCompatible(state?.vendor, state?.equipmentId) }
val visible = if (showAll) model.firmwareFiles else compatible
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Update firmware") },
text = {
Column {
state?.fwVersion?.let { Text("Current: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
Spacer(Modifier.height(8.dp))
when {
model.firmwareError != null -> Text(model.firmwareError!!, color = MaterialTheme.colorScheme.error)
model.firmwareFiles.isEmpty() -> Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp); Spacer(Modifier.width(8.dp)); Text("Loading…") }
else -> Column {
visible.forEach { file ->
ListRow(onClick = { pending = file }) {
Column(Modifier.weight(1f)) {
Text(file.version ?: file.filename ?: "Unknown", fontWeight = FontWeight.Medium)
Text(listOfNotNull(file.models.firstOrNull(), file.sizeBytes?.let { Format.bytes(it) }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
if (model.firmwareFiles.size > compatible.size) {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = showAll, onCheckedChange = { showAll = it }); Spacer(Modifier.width(8.dp)); Text("Show all firmware")
}
}
}
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
pending?.let { file ->
AlertDialog(
onDismissRequest = { pending = null },
title = { Text("Stage firmware?") },
text = { Text("Stage ${file.version ?: "this image"} on ${model.onuId} in the standby bank. It becomes active after the ONU's next reboot — this is service-affecting.") },
confirmButton = {
TextButton(onClick = { pending = null; onDismiss(); scope.launch { model.upgrade(file) } }) {
Text("Download ${file.version ?: "firmware"}", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { pending = null }) { Text("Cancel") } },
)
}
}

View file

@ -0,0 +1,31 @@
package no.svorka.mcms.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val Orange = Color(0xFFF39200)
private val Navy = Color(0xFF0B2A4A)
private val LightColors = lightColorScheme(
primary = Orange,
onPrimary = Color.White,
secondary = Navy,
)
private val DarkColors = darkColorScheme(
primary = Orange,
onPrimary = Color.Black,
secondary = Color(0xFF9FB8D6),
)
@Composable
fun PonGoTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors,
content = content,
)
}

View file

@ -0,0 +1,35 @@
package no.svorka.mcms.ui
import androidx.compose.ui.graphics.Color
import no.svorka.mcms.core.AlarmSeverity
import no.svorka.mcms.core.OnuLifecycleState
/** Screen load state. */
sealed interface LoadPhase {
data object Idle : LoadPhase
data object Loading : LoadPhase
data object Loaded : LoadPhase
data class Failed(val message: String) : LoadPhase
}
fun OnuLifecycleState.color(): Color = when (this) {
OnuLifecycleState.REGISTERED -> Color(0xFF2E7D32)
OnuLifecycleState.UNSPECIFIED -> Color(0xFF26A69A)
OnuLifecycleState.UNPROVISIONED -> Color(0xFF1565C0)
OnuLifecycleState.DEREGISTERED -> Color(0xFF757575)
OnuLifecycleState.DYING_GASP, OnuLifecycleState.DISALLOWED_ERROR -> Color(0xFFC62828)
OnuLifecycleState.DISABLED, OnuLifecycleState.DISALLOWED_ADMIN, OnuLifecycleState.DISALLOWED_REG_ID -> Color(0xFFEF6C00)
OnuLifecycleState.UNKNOWN -> Color(0xFF9E9E9E)
}
fun AlarmSeverity.color(): Color = when (this) {
AlarmSeverity.EMERGENCY, AlarmSeverity.ALERT, AlarmSeverity.CRITICAL -> Color(0xFFC62828)
AlarmSeverity.ERROR -> Color(0xFFEF6C00)
AlarmSeverity.WARNING -> Color(0xFFF9A825)
AlarmSeverity.NOTICE, AlarmSeverity.INFO -> Color(0xFF1565C0)
AlarmSeverity.DEBUG -> Color(0xFF757575)
AlarmSeverity.UNKNOWN -> Color(0xFF9E9E9E)
}
/** Maps a thrown error to a user message. */
fun Throwable.uiMessage(): String = message ?: "Something went wrong"

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#002049</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PON Go</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Window theme only; the UI itself is drawn by Jetpack Compose / Material 3. -->
<style name="Theme.MCMS" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

7
build.gradle.kts Normal file
View file

@ -0,0 +1,7 @@
// Top-level build file. Plugin versions are declared here and applied per-module.
plugins {
id("com.android.application") version "8.11.0" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
}

758
docs/MCMS_API.md Normal file
View file

@ -0,0 +1,758 @@
# Ciena MCMS 6.2 REST API — Implementer's Field Guide
Reference for building an HTTP client against a Ciena MicroClimate
Management System (MCMS) 6.2 PON Manager. Distilled from the official
dev guide (`323-1961-306`) plus empirical findings against a working
deployment with GNXS / Tibit MicroPlug ONUs on Interos Everest 5.x
firmware.
Language-agnostic — every quirk listed here was hit in production. If
you skip the gotchas in §3, you will lose an afternoon to one of them.
---
## 1. Connection basics
- **Base URL.** The root of the PON Manager web UI (e.g.
`https://mcms.example.net`). All REST paths are prefixed with `/api`.
- **Transport.** HTTPS. Internal deployments commonly use self-signed
certificates on private IPs — your client needs an opt-in to skip
TLS verification for that case (do **not** disable globally).
- **HTTP version.** HTTP/1.1 works. The server also negotiates HTTP/2
but the dev-guide examples are 1.1 and the only behavioural
difference observed is irrelevant to clients.
- **Path normalisation.** The dev guide writes paths as
`/v1/users/authenticate/` — the real wire path is
`/api/v1/users/authenticate/`. Normalise once in your client.
- **API versions.** Both `/api/v1/...` and `/api/v3/...` exist and serve
different endpoints. **Use whichever the dev guide lists for that
resource — they are not interchangeable.** See §4 endpoint table.
- **Trailing slashes.** Required. `/api/v1/onus/configs/<id>/` works;
`/api/v1/onus/configs/<id>` does not (Django's `APPEND_SLASH` is
on, but a few endpoints 500 instead of redirecting).
---
## 2. Login & session
MCMS uses Django session cookies + a CSRF token. Sessions do **not**
auto-expire on idle — log out explicitly when done.
### 2.1 Authenticate
```
POST /api/v1/users/authenticate/
Content-Type: application/json
{ "data": { "email": "user@example.net", "password": "..." } }
```
Response on success (`HTTP 200`):
```json
{ "status": "success" }
```
Set-Cookie headers carry the session. On observed MCMS 6.2 builds:
```
Set-Cookie: __Host-csrftoken=<token>; Path=/; Secure; SameSite=Lax
Set-Cookie: __Host-sessionid=<id>; Path=/; Secure; HttpOnly; SameSite=Lax
Set-Cookie: __Host-sessionexpire=<epoch>; Path=/; Secure; SameSite=Lax
```
**The `__Host-` prefix is significant.** Some MCMS builds (older
ones, mostly) use bare `csrftoken` / `sessionid`. Your cookie reader
should accept either prefix.
→ Some build variants accept an unwrapped body too:
`{"email": ..., "password": ...}` with no `data` envelope. If wrapped
returns HTTP 400, retry unwrapped.
### 2.2 Subsequent requests
Every authenticated request must send:
```
Cookie: __Host-csrftoken=<token>; __Host-sessionid=<id>; ...
X-CSRFToken: <token> # the value from __Host-csrftoken
Referer: <baseURL>/ # MCMS rejects POST/PUT/DELETE without this
User-Agent: <anything> # see §3.1 — must not be empty
Accept: application/json
```
For PUT/POST/PATCH also:
```
Content-Type: application/json
Content-Length: <bytes>
```
### 2.3 (Optional) Database selection
If the deployment manages multiple databases:
```
PUT /api/v1/databases/selection/
{ "data": "<database_id>" }
```
Most deployments only have one and skip this step.
### 2.4 Logout
```
GET /api/v1/users/logout/
```
Best-effort — don't fail your client if this errors.
---
## 3. The gotchas (read this section twice)
### 3.1 User-Agent header is REQUIRED
If you don't send a `User-Agent`, some MCMS middleware dereferences
`HTTP_USER_AGENT` without a null check and returns **HTTP 500** with
body:
```json
{"status": "fail", "details": {"message": "Internal server error. See server logs for details."}}
```
Reproduce: `curl -A '' https://mcms/api/v3/onus/configs/`. Any non-empty
string works (`PonFleetClient/1.0`, etc.).
**For iOS/Swift**: `URLSession` sets a default User-Agent automatically
(e.g. `<bundle>/1 CFNetwork/... Darwin/...`), so this is unlikely to
bite you there — but if you ever set a custom `URLRequest` with
`setValue(nil, forHTTPHeaderField: "User-Agent")`, the 500s will
return.
### 3.2 CSRF token field name
The token cookie is `__Host-csrftoken` on new builds, bare `csrftoken`
on older. The header you send back is always `X-CSRFToken` (no
prefix, capital `T` in `Token`). Both Django case-fold the header
name, so casing is fine — but the body name is exact.
### 3.3 Pagination: use `next`, NOT `skip`
`/api/v3/onus/configs/?skip=N` has been observed returning HTTP 500.
The working approach uses the last document's `_id` as a cursor:
```
GET /api/v3/onus/configs/?limit=100
→ returns 100 docs, the last with _id = "X"
GET /api/v3/onus/configs/?limit=100&next=X
→ returns the next 100, EXCLUDING all _ids <= X
```
Loop until you get a short page (< limit) or an empty page. Most list
endpoints accept this pattern.
### 3.4 Page size 100 is the safe default
`?limit=1000` against `/api/v3/onus/configs/` on a ~1500-ONU fleet
times out at the Apache front-end before Django logs the request.
Full `ONU-CFG` documents are ~30 KB each, so 1000 × 30 KB = 30 MB,
which exceeds the proxy timeout. **100 per page keeps each response
~3 MB** and well under any default proxy budget.
This applies to all bulk list endpoints (configs, states, OLT states).
### 3.5 Request body envelope: `{"data": payload}`
PUT/POST/PATCH bodies are wrapped:
```json
{ "data": { "_id": "...", "ONU": { ... } } }
```
Response envelope is similar:
```json
{ "status": "success", "data": { ... } }
{ "status": "fail", "details": { "message": "..." } }
{ "status": "warning", "data": { ... }, "details": { ... } }
```
`"warning"` is treated as success by MCMS (the write happened) but
indicates a schema mismatch you should log.
### 3.6 PUT is full-document replace, NOT patch
`PUT /api/v1/onus/configs/<id>/` replaces the entire `ONU-CFG`
document. You **must** GET the current doc, mutate fields, and PUT
the entire result. There is no PATCH equivalent that works reliably
on MCMS 6.2.
Forgetting this turns into "I PUT'd just the FW Bank fields and now
the ONU has no service config" — the dev guide is explicit but easy
to skim past.
### 3.7 Timestamp format
MCMS emits and accepts timestamps as:
```
"YYYY-MM-DD HH:MM:SS" # second precision
"YYYY-MM-DD HH:MM:SS.ffffff" # microsecond precision (for STATE.Time)
```
Always **UTC**, always with a **literal space** between date and time
(not ISO-8601 `T`). Used on:
- `ONU-STATE.Time`
- `OLT-STATE.Time`
- Alarm timestamps
- `AUTO-TASK-CFG.Task["Scheduled Start Time"]`
- `start-time` / `end-time` URL params on stats endpoints
**For Swift**: `ISO8601DateFormatter` will NOT parse this. Use a
plain `DateFormatter` with:
```swift
let fmt = DateFormatter()
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS" // or "yyyy-MM-dd HH:mm:ss"
fmt.locale = Locale(identifier: "en_US_POSIX")
```
The `en_US_POSIX` locale matters — without it, devices set to a
24-hour-format locale will still parse fine but devices set to
12-hour-format locales will fail. POSIX locale forces parse rules.
### 3.8 Server-side query/projection filters are brittle
The dev guide documents `query`, `projection`, `sort`, `limit`,
`skip`, `next`, `distinct` URL parameters. In practice:
- **`query`** (Mongo-style filter) works for simple equality on
bare field names. Anything with **spaces in field names**, quoted
values with non-ASCII characters, or projection paths that don't
exist on every document version is unreliable — you'll get either
empty results or 400s.
- **`projection`** is *very* picky about paths. Better to fetch full
docs and project client-side.
**Recommended approach**: load the full fleet (paginated by `next`)
and filter client-side. The data volume is fine for any device with
enough RAM to hold a few thousand 30 KB documents.
### 3.9 URL encoding
Use percent-encoding (`encodeURIComponent` equivalent). MCMS does NOT
accept `+` for spaces in URL parameters — use `%20`.
This matters for query params like `start-time` which carry literal
spaces in the timestamp.
### 3.10 ONU IDs
The `_id` field of an ONU document is:
- The **serial number** for XGS-PON / GPON ONUs (e.g. `GNXS05057fee`)
- The **MAC address** for 10G-EPON ONUs
Treat as opaque strings. They're URL-safe in observed deployments
but always `encodeURIComponent` to be defensive.
### 3.11 Registration status lives on OLT-STATE, not ONU-STATE
This is non-obvious. The 9 registration buckets live as arrays on the
OLT's state document:
```json
{
"_id": "<OLT MAC>",
"ONU States": {
"Registered": ["GNXS01", "GNXS02", ...],
"Deregistered": ["GNXS09", ...],
"Dying Gasp": [...],
"Disabled": [...],
"Disallowed Admin": [...],
"Disallowed Error": [...],
"Disallowed Reg ID": [...],
"Unspecified": [...],
"Unprovisioned": [...]
}
}
```
To resolve "what's the status of ONU X?", you walk every OLT-STATE
doc, find which bucket contains X, and that's your answer. See
Procedure 2 in the dev guide.
An ONU should appear in exactly one bucket on exactly one OLT. In the
rare duplicate case, prefer the non-`Registered` bucket — that's the
interesting signal.
### 3.12 FEC counters live on ONU-STATE.STATS, not on `/onus/stats/`
`GET /api/v1/onus/stats/<id>/?start-time=...` returns historical PM
samples (time-series). For the *current* FEC counters and optical
levels, read the live `ONU-STATE` document:
```
GET /api/v3/onus/states/<id>/
→ data.STATS["ONU-PON"]["RX Pre-FEC BER"]
→ data.STATS["ONU-PON"]["RX Post-FEC BER"]
→ data.STATS["ONU-PON"]["RX Optical Level"] (dBm)
→ data.STATS["ONU-PON"]["TX Optical Level"] (dBm)
→ data.STATS["OLT-PON"]["RX Pre-FEC BER"]
→ data.STATS["OLT-PON"]["RX Post-FEC BER"]
```
The MCMS Web UI's `/api/onu/summary` helper returns the same data
under `data.state_collection.STATS.*` (wrapper shape).
### 3.13 Bulk endpoints don't return everything in one shot
Even on a small fleet, MCMS may silently truncate large list
responses. Always paginate — don't trust `?limit=large_number` to be
authoritative.
### 3.14 Verbose error logging matters
When MCMS 500s, the response body sometimes contains useful detail
(missing required param, schema validation failure, etc.). Always log
both the response headers and the FULL body on non-2xx during
development — most "mysterious 500" cases resolve to a one-line hint
in the body.
---
## 4. Endpoints used by the fleet-upgrade app
| Method | Path | Purpose |
|---|---|---|
| `POST` | `/api/v1/users/authenticate/` | Login |
| `GET` | `/api/v1/users/logout/` | Logout |
| `PUT` | `/api/v1/databases/selection/` | (Optional) DB selection |
| `GET` | `/api/v3/onus/configs/` | List ONU configs (paginated) |
| `GET` | `/api/v1/onus/configs/<id>/` | Get single ONU config |
| `PUT` | `/api/v1/onus/configs/<id>/` | Replace ONU config (full doc) |
| `DELETE` | `/api/v1/onus/configs/<id>/` | Delete ONU (Procedure 43) |
| `GET` | `/api/v3/onus/states/` | List ONU states (paginated) |
| `GET` | `/api/v3/onus/states/<id>/` | Get single ONU state |
| `DELETE` | `/api/v1/onus/states/<id>/` | Delete ONU state (best-effort) |
| `GET` | `/api/v3/olts/states/` | List OLT states (paginated; registration) |
| `GET` | `/api/v1/files/onu-firmware/` | List uploaded firmware files |
| `POST` | `/api/v1/files/onu-firmware/<name>/` | Upload firmware |
| `PUT` | `/api/v3/tasks/configs/<id>/` | Create bulk upgrade task (Procedure 8) |
| `GET` | `/api/v3/tasks/configs/<id>/` | Poll bulk task config |
| `GET` | `/api/v1/onus/<id>/upgrade/status/` | (Build-dependent) per-ONU status |
### Other endpoints documented but not (yet) used
| Method | Path | Notes |
|---|---|---|
| `GET` | `/api/v1/onus/stats/<id>/?start-time=...` | Time-series ONU PM data |
| `GET` | `/api/v1/olts/stats/<id>/?start-time=...` | OLT PM data |
| `GET` | `/api/v1/onus/logs/<id>/?start-time=...` | Per-ONU syslog |
| `GET` | `/api/v1/olts/logs/<id>/?start-time=...` | Per-OLT syslog |
| `GET` | `/api/v3/tasks/states/<id>/` | Bulk task progress (good follow-up) |
| `GET` | `/api/v1/onus/automation/states/` | PON automation state |
| `GET` | `/api/v1/onus/cpe-states/` | ONU CPE states (downstream devices) |
---
## 5. Data shapes (observed)
These are not formally specified in the dev guide for MCMS 6.2 — they
were extracted from live traffic.
### 5.1 ONU-CFG
```jsonc
{
"_id": "GNXS05057fee",
"ONU": {
"Name": "Operator-set label",
"Address": "Street, city",
"PON Mode": "GPON", // GPON | XGS-PON | 10G-EPON
"Equipment ID": "FT-XGS2110",
"FW Bank Files": ["...bin", "...bin"], // [slot 0, slot 1]
"FW Bank Versions": ["EV05110R", "EV05100R"],
"FW Bank Ptr": 0, // 0 | 1 | 65535 (unset)
"ONU-ALARM-CFG": "Default",
"Vendor": "GNXS",
"Serial Number": "GNXS05057fee",
"Host MAC Address": "58:00:32:05:7f:ee",
// ...many more fields - preserve all of them when PUTing
},
"OLT-Service 0": { ... }, // service config slots
"OLT-Service 1": { ... },
// ...
}
```
**Critical**: when mutating for an upgrade, only change `ONU["FW Bank
Files"]`, `ONU["FW Bank Versions"]`, and `ONU["FW Bank Ptr"]`. Pass
through everything else unchanged.
### 5.2 ONU-STATE
```jsonc
{
"_id": "GNXS05057fee",
"Time": "2026-05-04 21:51:57.394127", // UTC, last update
"ONU": {
"Equipment ID": "FT-XGS2110",
"FW Bank Files": [...],
"FW Bank Versions": [...],
"FW Bank Ptr": 0,
"FW Version": "EV05110R", // resolved active version
"FW Upgrade Status": {
"Bank": 0,
"Status": "Success", // status string
"Progress": 100, // percent
"Upgrade Time": "2026-03-18 17:12:43.093893",
"Total Blocks": 482915,
"Sent Blocks": 482915,
"Failures": 0,
// ...
},
"PON Mode": "GPON",
"Online Time": "2026-03-18 17:13:42.285965",
// ...
},
"STATS": {
"ONU-PON": {
"RX Pre-FEC BER": 22388041291, // cumulative since reset
"RX Post-FEC BER": 22379111198,
"RX Optical Level": -17.544, // dBm
"TX Optical Level": 5.532 // dBm
},
"OLT-PON": {
"RX Pre-FEC BER": 0, // upstream view
"RX Post-FEC BER": 0,
"TX Optical Level": 5.6,
"RX Optical Level": -18.3
},
"ONU-EnhancedFecPmHistData-32769": { // NOT the same as the BER fields above
"uncorrectable_code_words": 1829, // (these are codeword counts,
"corrected_bytes": 181, // not bit-error-rate ratios)
"corrected_code_words": 181
},
// ...many more
},
"Alarm": {
"0-EMERG": [], "1-ALERT": [], "2-CRIT": [],
"3-ERROR": [], "4-WARNING": [], "5-NOTICE": [],
"6-INFO": [], "7-DEBUG": []
}
}
```
### 5.3 OLT-STATE (relevant subset)
```jsonc
{
"_id": "<OLT MAC>",
"Time": "2026-05-04 21:51:57.394127",
"ONU States": {
"Registered": ["onu1", "onu2", ...],
"Deregistered": [...],
"Dying Gasp": [...],
"Disabled": [...],
"Disallowed Admin": [...],
"Disallowed Error": [...],
"Disallowed Reg ID": [...],
"Unspecified": [...],
"Unprovisioned": [...]
}
// ...
}
```
### 5.4 AUTO-TASK-CFG (bulk firmware task, Procedure 8)
```jsonc
{
"_id": "<your-task-id>", // unique, caller-generated
"Task": {
"Device Type": "ONU",
"Operation": "Firmware Upgrade",
"Scheduled Start Time": "2026-05-12 03:00:00" // UTC space format
},
"Task Details": {
"Devices": ["GNXS01", "GNXS02", ...], // ONU IDs (serial / MAC)
"ONU": {
"FW Bank Ptr": 1, // target slot (single value!)
"FW Bank Files": { "0": "old.bin", "1": "new.bin" },
"FW Bank Versions": { "0": "EV05110R", "1": "EV05120R" }
}
}
}
```
PUT to `/api/v3/tasks/configs/<task-id>/`. Note: `FW Bank Files` and
`FW Bank Versions` here are **objects keyed by slot index as strings**
(`"0"`, `"1"`), not arrays. This is different from the `ONU-CFG`
shape where the same fields are arrays — almost certainly a Mongo
serialisation quirk. Match it as-is.
**Single bank per task.** Because `FW Bank Ptr` accepts one slot, if
your selection spans ONUs with different active banks, bucket them
by target slot and create one task per bucket.
### 5.5 Firmware file entry (from `/api/v1/files/onu-firmware/`)
```jsonc
{
"_id": "...",
"filename": "Interos-Everest-5.12.0-R-EV05120R.bin",
"metadata": {
"Compatible Manufacturer": "TIBITCOM",
"Compatible Model": ["MicroPlug ONU"],
"Version": "EV05120R"
},
// GridFS metadata...
}
```
To upload:
```
POST /api/v1/files/onu-firmware/<filename>/
{
"data": {
"file": "<base64 binary contents>",
"metadata": {
"Compatible Manufacturer": "TIBITCOM",
"Compatible Model": ["MicroPlug ONU"],
"Version": "EV05120R"
}
}
}
```
---
## 6. Firmware upgrade flow
Two methods documented:
### 6.1 Procedure 7 — per-ONU PUT
For each ONU:
1. `GET /api/v1/onus/configs/<id>/` → full doc.
2. Compute the inactive bank (see §6.3).
3. Set:
- `ONU["FW Bank Files"][writeSlot] = "<filename>"`
- `ONU["FW Bank Versions"][writeSlot] = "<version>"`
- `ONU["FW Bank Ptr"] = writeSlot` (this initiates the download)
4. `PUT /api/v1/onus/configs/<id>/` with the entire mutated document.
Pros: see errors per ONU live. Cons: slow for large fleets, you own
retries.
### 6.2 Procedure 8 — bulk task (recommended)
Build one `AUTO-TASK-CFG` per (target slot) bucket:
```
PUT /api/v3/tasks/configs/<task-id>/
(body shape in §5.4)
```
MCMS owns the rollout: scheduled start, retries, pacing.
Monitor progress (build permitting):
```
GET /api/v3/tasks/states/<task-id>/
```
Or just poll the ONU configs and watch `FW Bank Versions` /
`FW Bank Ptr` flip.
### 6.3 Inactive-bank selection
`FW Bank Ptr` identifies the active bank (0, 1, or 65535 = unset).
**Always write to the inactive bank** so you don't blow away the
running image:
| Current `FW Bank Ptr` | Write slot | Why |
|---|---|---|
| 0 | 1 | preserve active |
| 1 | 0 | preserve active |
| 65535 (unset) | 1 | matches Procedure 7 example; slot 0 stays as factory fallback |
MCMS flips `FW Bank Ptr` to the new slot when the download completes.
The ONU reboots into the new image on its next reboot.
---
## 7. FEC + optical interpretation
### 7.1 Four FEC counters
Per the user guide green-LED criteria (`323-1961-302` p149):
| Field | Path | Green | Red |
|---|---|---|---|
| ONU Pre-FEC BER | `STATS["ONU-PON"]["RX Pre-FEC BER"]` | 0 | (informational) |
| ONU Post-FEC BER | `STATS["ONU-PON"]["RX Post-FEC BER"]` | 0 | > 0 |
| OLT Pre-FEC BER | `STATS["OLT-PON"]["RX Pre-FEC BER"]` | 0 | (informational) |
| OLT Post-FEC BER | `STATS["OLT-PON"]["RX Post-FEC BER"]` | 0 | > 0 |
Note: the field name is "BER" but the magnitudes are big integers
(cumulative error byte counts since reset), not ratios in [0,1].
Treat them as counts.
### 7.2 Health bucket rules
Suggested 4-state reduction:
- **OK**: all four counters present and == 0.
- **Pre-FEC errors**: any pre > 0, all post == 0. (Marginal link;
FEC is correcting.)
- **Post-FEC errors**: any post > 0. (Uncorrected bits reaching the
user.)
- **Buggy (pre ≈ post)**: ONU pre > 0 AND post > 0 AND
`post / pre >= 0.95`. Real FEC reduces errors; if post is within
5% of pre, the firmware is mirroring the counter. Observed on
Interos Everest ONUs.
### 7.3 Optical levels
```
STATS["ONU-PON"]["RX Optical Level"] // ONU receive power, dBm
STATS["ONU-PON"]["TX Optical Level"] // ONU transmit power, dBm
```
User guide thresholds:
| | Green | Red |
|---|---|---|
| RX | ≥ 30 dBm | < 30 dBm |
| TX | ≥ 3 dBm | < 3 dBm |
Operators usually want a margin warning band — e.g. yellow when RX
is between 28 and 30 dBm.
---
## 8. iOS-specific notes
### 8.1 Library stack suggestions
- **HTTP**: `URLSession` is fine. The features you need are standard
cookie storage, custom headers, and the option to bypass cert
validation. Don't reach for Alamofire just for this.
- **Cookies**: `HTTPCookieStorage` handles `__Host-` prefixes
correctly. Get the CSRF value via:
```swift
HTTPCookieStorage.shared.cookies(for: baseURL)?
.first { $0.name == "__Host-csrftoken" || $0.name == "csrftoken" }?.value
```
- **Self-signed certs**: Implement
`URLSessionDelegate.urlSession(_:didReceive:completionHandler:)`
and accept `serverTrust` only when the user has explicitly toggled
"accept self-signed" for the configured host. Never default-on.
- **JSON**: `JSONSerialization` is simpler than `Codable` for the
envelope pattern (`{status, data, details}`) because the `data`
payload is heterogeneous. `Codable` works if you model each
endpoint individually.
### 8.2 Concurrency
The fleet load is N HTTPS round-trips for per-ONU state (the bulk
endpoint is the optimisation but the per-ID fallback may also be
needed). Use Swift Concurrency (`TaskGroup`) with a concurrency cap
of 810 to avoid stampeding the server. The desktop client uses 5
for deletes and 8 for state fetches.
### 8.3 Background uploads
Firmware `.bin` files are 1050 MB base64-encoded. If you implement
uploads:
- Don't block the main thread.
- Consider `URLSession` background configuration so an upload can
survive an app suspend.
- The upload is a single POST; no chunking required.
### 8.4 Storing the session
For UX: persist the base URL and username in `UserDefaults`, never
the password. Re-prompt for password on launch.
The session cookie itself goes in the per-session cookie storage
and is automatically discarded — don't try to persist it.
### 8.5 Date handling
See §3.7. Default `DateFormatter` with `en_US_POSIX` locale + UTC
timezone, format `"yyyy-MM-dd HH:mm:ss.SSSSSS"` (microseconds) or
`"yyyy-MM-dd HH:mm:ss"` (seconds). Try the microsecond format
first, fall back to second if it fails.
### 8.6 Cell-vs-Wi-Fi
MCMS APIs are typically reachable only from the operator's internal
network. Test on cellular: many internal hosts won't be reachable,
and your error UI needs to communicate that clearly.
### 8.7 Self-signed cert prompt
Make this a first-class onboarding step. The lab MCMS at
`https://mcms.lab.svorka.net/` uses a self-signed cert, and any
auto-rejection will silently break login.
---
## 9. Procedures referenced in the dev guide
| # | Title | Notes |
|---|---|---|
| 2 | Determining ONU registration status | OLT-STATE["ONU States"] buckets |
| 7 | Per-ONU firmware upgrade | Full-doc PUT to ONU-CFG |
| 8 | Bulk firmware upgrade | AUTO-TASK-CFG via PUT |
| 38 | Resetting bit error rate values | Names the four FEC fields |
| 43 | Deleting an ONU | DELETE on ONU-CFG |
(Page numbers vary by document revision; cite by procedure number.)
---
## 10. Quick implementation checklist for a new client
A correct first POST-login GET against `/api/v3/onus/configs/?limit=1`
returning HTTP 200 means you've got the eight critical pieces right:
- [ ] HTTPS reachable (with cert exception if needed)
- [ ] Path prefix `/api` added
- [ ] User-Agent set to non-empty value
- [ ] Cookie jar storing `__Host-sessionid` + `__Host-csrftoken`
- [ ] `X-CSRFToken` header pulled from `__Host-csrftoken` cookie value
- [ ] `Referer` header set to base URL
- [ ] `Accept: application/json`
- [ ] Trailing slash on the path
Add for write operations:
- [ ] `Content-Type: application/json`
- [ ] Body wrapped: `{"data": ...}`
- [ ] Full-document PUT (never partial)
Add for list endpoints:
- [ ] Page size ≤ 100
- [ ] `next` cursor (not `skip`)
- [ ] Loop until short page
Once those land, the API is comfortable to work with — the hard parts
are upfront.
---
*Compiled from `323-1961-306` (REST API dev guide), `323-1961-302`
(PON Manager User Guide), and empirical testing against MCMS 6.2 with
Interos Everest 5.11/5.12 firmware on GNXS / Tibit MicroPlug ONUs.*

4
gradle.properties Normal file
View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

17
settings.gradle.kts Normal file
View file

@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "MCMS"
include(":app")