API Reference

mirror-api

The shared contract in :mirror-api that both the app and every plugin compile against. Package org.speculum.

On this page

MirrorModule

org.speculum.core.MirrorModule — base class every module extends. Modeled on MagicMirror's Module.register. Lifecycle: start() → (loop refresh()) → Content() rendered → stop().

abstract class MirrorModule(
    val config: ModuleConfig
) : NotificationListener {

    val name: String get() = config.module
    val region: Region get() = config.regionEnum
    open val refreshIntervalMs: Long get() = config.refreshIntervalMs

    open fun start(scope: CoroutineScope) {}        // once, at boot
    open suspend fun refresh() {}                   // every refreshIntervalMs
    open fun stop() {}                              // at shutdown
    override fun onNotification(notification: Notification) {}  // bus events

    @Composable
    abstract fun Content()                          // the UI — required
}
MemberNotes
start(scope)Called once. Launch coroutines in scope (auto-cancelled on shutdown) — clocks, animations, one-off setup.
refresh()Engine calls it on a loop every refreshIntervalMs (first call immediate). Put data fetching here. Return 0 from refreshIntervalMs to skip the loop.
stop()Release resources.
onNotification(n)Receive bus events (e.g. "ALL_MODULES_STARTED").
Content()Required. The composable UI. Hold state in mutableStateOf(...); assigning recomposes.

Only Content() is required; override the rest as needed.

ModuleConfig

org.speculum.config.ModuleConfig — one entry in the modules array, equivalent to one object in MagicMirror's config.js. config is a free-form string map so each module reads its own options.

@Serializable
data class ModuleConfig(
    val module: String,
    val position: String = "top_left",
    @SerialName("refreshInterval") val refreshIntervalMs: Long = 60_000,
    val config: Map<String, String> = emptyMap()
) {
    val regionEnum: Region
        get() = runCatching { Region.valueOf(position.uppercase()) }
            .getOrDefault(Region.TOP_LEFT)

    fun string(key: String, default: String = ""): String
    fun int(key: String, default: Int = 0): Int
    fun bool(key: String, default: Boolean = false): Boolean
}
GetterBehavior
string(key, default)Raw map value or default.
int(key, default)toIntOrNull() or default.
bool(key, default)toBooleanStrictOrNull() or default.
refreshIntervalMsThe entry's refreshInterval (JSON key), in milliseconds.

MirrorConfig

Top-level config, equivalent to MagicMirror's config.js.

@Serializable
data class MirrorConfig(
    val language: String = "en",
    val timeFormat: Int = 24,
    val units: String = "metric",
    val modules: List<ModuleConfig> = emptyList()
)

ModuleFactory

org.speculum.core.ModuleFactory — the SPI for pluggable JAR modules. Implement it and declare the FQN in META-INF/services/org.speculum.core.ModuleFactory.

interface ModuleFactory {
    val name: String                                 // config `module` name
    fun create(config: ModuleConfig): MirrorModule   // build an instance
    fun defaultConfig(): ModuleConfig? = null         // auto-added placement
    fun settingsSchema(): List<SettingSpec> = emptyList()  // admin-console controls
    val order: Int get() = 0                          // stacking within a region
}
MemberNotes
nameThe config module name this factory handles (e.g. "example").
create(config)Build a module instance for the given config entry.
defaultConfig()Optional default placement, auto-added when no entry already names this module. Return null to require an explicit entry.
settingsSchema()Declares the config options this module understands, so the admin console renders labelled, typed controls instead of raw key/value rows. See SettingSpec. Empty by default, which keeps the raw rows.
orderStacking order within a region (lower = higher/earlier). Keeps layout deterministic when JARs load in unspecified order.
class FooModuleFactory : ModuleFactory {
    override val name = "foo"
    override fun create(config: ModuleConfig) = FooModule(config)
    override fun defaultConfig() = ModuleConfig("foo", "top_center", 3000, mapOf("k" to "v"))
    override fun settingsSchema() = listOf(
        SettingSpec("k", "Label", help = "What this option does."),
    )
}

SettingSpec

org.speculum.config.SettingSpec — one declared entry of a module's config map. The admin console has no built-in knowledge of any module: it renders whatever settingsSchema() returns, served through GET /api/modules. Keys saved in a config but absent from the schema still render as raw key/value rows, so a schema may describe only part of a module's options.

@Serializable
data class SettingSpec(
    val key: String,                       // the config map key
    val label: String,                     // shown next to the control
    val type: SettingType = SettingType.STRING,
    val default: String = "",              // what the module falls back to
    val options: List<String> = emptyList(),  // ENUM values
    val min: Int? = null,                  // INT bounds
    val max: Int? = null,
    val step: Int? = null,
    val help: String = "",                 // one sentence under the control
    val placeholder: String = "",
    val editor: String = "",               // editor name for CUSTOM
    val preview: List<String> = emptyList(),  // "effective value" templates
    val advanced: Boolean = false,         // fold behind "show advanced"
)

enum class SettingType { STRING, INT, BOOL, ENUM, TEXT, URL, IP, CUSTOM }
TypeControl
stringSingle-line text field.
intNumber field, bounded by min/max and stepped by step.
boolCheckbox storing "true"/"false".
enumDropdown over options. A saved value no longer offered stays selectable.
textTextarea for multi-line values.
urlFull-width text field with URL validation.
ipDropdown of the host addresses the server detects, plus auto-detect.
customPurpose-built editor looked up by editor; falls back to a textarea when the console has no editor of that name.

preview renders an "effective value" hint below the control. Each template interpolates {key} from the module's current config, and {key|fallback} substitutes fallback when that key is blank. The first template whose plain {key} references all resolve is shown, so a module can list a specific value first and a derived one after it:

SettingSpec(
    key = "url", label = "URL override", type = SettingType.URL,
    help = "Leave blank to build the address from the IP and port.",
    preview = listOf("{url}", "http://{ip|<auto LAN IP>}:{port|8080}"),
)

Region

org.speculum.core.Region — screen regions; the layout engine places each module in the matching region of the fullscreen dashboard.

enum class Region {
    TOP_LEFT, TOP_CENTER, TOP_RIGHT,
    UPPER_THIRD, MIDDLE_CENTER, LOWER_THIRD,
    BOTTOM_LEFT, BOTTOM_CENTER, BOTTOM_RIGHT,
    BOTTOM_BAR, FULLSCREEN_ABOVE, FULLSCREEN_BELOW
}

Notification & NotificationListener

org.speculum.core — inter-module messaging, equivalent to MagicMirror's sendNotification / notificationReceived bus.

data class Notification(
    val name: String,
    val payload: Any? = null,
    val sender: String? = null
)

interface NotificationListener {
    fun onNotification(notification: Notification)
}

The engine broadcasts events (e.g. ALL_MODULES_STARTED). Broadcasting from a module needs an engine reference — today the engine is the broadcaster.

MirrorColors

org.speculum.ui.MirrorColors — the three brightness levels on black.

object MirrorColors {
    val Bright = Color(0xFFFFFFFF)   // headlines, values
    val Normal = Color(0xFF999999)   // labels
    val Dimmed = Color(0xFF666666)   // secondary / headers
}
Icons must be pure-white vectors. A two-way mirror shows only bright pixels, and Compose Desktop falls back to a color-emoji font for many glyphs — so draw icons with Canvas rather than emoji. Plain geometric text glyphs (• ↑ ≈) render monochrome and are fine.

Config-server REST API

Auth is Authorization: Bearer <token> (HMAC-signed, 12 h TTL) obtained from /api/login.

MethodPathAuthBody / Result
POST/api/loginnone{password} → {token} (12 h)
POST/api/passwordbearer{currentPassword, newPassword} → {message}
GET/api/modulesbearerdiscovered modules + default configs + settings schemas
GET/api/configbearereffective MirrorConfig
PUT/api/configbearersave MirrorConfig → writes config.json

POST /api/password → 401 if currentPassword wrong, 400 if newPassword < 4 chars. Changing the password does not revoke existing tokens — they expire on their own TTL.

Use mirror-api as a dependency

For a module developed in its own repo, depend on the published API instead of project(":mirror-api"). It's released to GitHub Packages as org.speculum:mirror-api on every v* tag (version = tag without the v; latest published 1.4.0). The package is public, but GitHub's Maven registry still requires a token with read:packages.

repositories {
    maven {
        url = uri("https://maven.pkg.github.com/pierrejochem/SpeculumSmartMirror")
        credentials {
            username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
            password = providers.gradleProperty("gpr.token").orNull ?: System.getenv("GITHUB_TOKEN")
        }
    }
}
dependencies { compileOnly("org.speculum:mirror-api:1.4.0") }

Put gpr.user / gpr.token in ~/.gradle/gradle.properties, or export GITHUB_ACTOR / GITHUB_TOKEN.

Full Developer Guide ↗ User Guide →