feat: ClaudeMeter — Wear OS rate-limit tracker for Claude Code

- wear: Compose for Wear UI, polls api.anthropic.com, shows 5h/7d utilization
- mobile: companion app — token input + Data Layer API + WiFi ADB install
- ADB pairing via libadb-android (SPAKE2 + TLS), pm install on watch
- Build: AGP 8.7.3, Gradle 8.13, Kotlin 1.9.22, compileSdk 35
This commit is contained in:
wenil
2026-06-19 11:43:24 +03:00
commit 4083609b12
19 changed files with 1301 additions and 0 deletions
@@ -0,0 +1,61 @@
package com.eps.claudemeter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.roundToInt
// ponytail: тот же запрос, что у Clawdmeter — POST с max_tokens=1 ради rate-limit заголовков.
// HttpURLConnection (stdlib), без OkHttp. Один запрос = один Usage.
object ApiPoller {
private const val URL_STR = "https://api.anthropic.com/v1/messages"
// ponytail: haiku — самый дешёвый, нужен только заголовок
private const val BODY =
"""{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}"""
suspend fun poll(token: String): Usage = withContext(Dispatchers.IO) {
val conn = (URL(URL_STR).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Authorization", "Bearer $token")
setRequestProperty("anthropic-version", "2023-06-01")
setRequestProperty("Content-Type", "application/json")
setRequestProperty("User-Agent", "claude-code/2.1.5")
doOutput = true
connectTimeout = 20000
readTimeout = 20000
}
try {
conn.outputStream.use { it.write(BODY.toByteArray()) }
val code = conn.responseCode
if (code >= 400) throw Exception("HTTP $code: ${conn.errorStream?.bufferedReader()?.use { it.readText() }?.take(200)}")
val h: (String) -> String? = { name -> conn.getHeaderField(name) }
Usage(
short = pct(h("anthropic-ratelimit-unified-5h-utilization")),
shortReset = resetMin(h("anthropic-ratelimit-unified-5h-reset")),
weekly = pct(h("anthropic-ratelimit-unified-7d-utilization")),
weeklyReset = resetMin(h("anthropic-ratelimit-unified-7d-reset")),
status = h("anthropic-ratelimit-unified-5h-status") ?: "unknown",
)
} finally {
conn.disconnect()
}
}
private fun pct(s: String?): Int =
s?.toFloatOrNull()?.let { (it * 100).roundToInt() } ?: 0
private fun resetMin(s: String?): Int {
val ts = s?.toFloatOrNull()?.toLong() ?: return 0
val mins = (ts - System.currentTimeMillis() / 1000) / 60
return if (mins > 0) mins.toInt() else 0
}
}
data class Usage(
val short: Int, // % загрузки 5-часового окна
val shortReset: Int, // минуты до сброса
val weekly: Int, // % недельного окна
val weeklyReset: Int, // минуты до сброса
val status: String, // normal / banned / unknown
)