Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
97760ef
Add ARCHITECTURE.md documenting module structure and integration patt…
claude Jul 14, 2026
211c3d5
Scaffold huawei-library and kafka-connect-huawei-source modules
claude Jul 14, 2026
d51979e
Implement huawei-library: routes, converters, and request generation
claude Jul 14, 2026
8b394ed
Implement kafka-connect-huawei-source Kafka Connect glue
claude Jul 14, 2026
bf05891
Wire up Huawei connector in docker-compose, README, and CI matrices
claude Jul 14, 2026
089a091
Add tests for huawei-library and the connector config
claude Jul 14, 2026
9267bc8
Fix ktlint style violations in huawei-library
claude Jul 14, 2026
9dac484
Fix ktlint style violations in kafka-connect-huawei-source
claude Jul 14, 2026
a6ec537
Add Onsentia affiliation to Huawei connector author metadata
claude Jul 14, 2026
2af0bb9
Fix CI type errors in HuaweiServiceUserRepository
claude Jul 14, 2026
59cdbc5
Fix ktlint line-length violation in build.gradle.kts
claude Jul 14, 2026
d7dbf15
Add Onsentia copyright header to Huawei connector source files
claude Jul 23, 2026
ac15a68
Fix copyright header in Huawei connector Dockerfile
claude Jul 23, 2026
401291b
Add @author yatharthranjan to Huawei connector source docs
claude Jul 23, 2026
4e23dbf
Add file-based HuaweiYamlUserRepository for local testing
claude Jul 28, 2026
9577b67
Fix Jackson getter conflict on HuaweiLocalUser.isAuthorized
claude Aug 4, 2026
5f69d0d
Log actual Huawei API error body on 400/401/403 responses
claude Aug 4, 2026
1ace5c1
Fix wrong query param name on the healthRecords GET route
claude Aug 4, 2026
33512fc
Fix healthRecords route against the official REST API reference
claude Aug 4, 2026
1a1b6df
Report the real HTTP status code for unclassified error responses
claude Aug 4, 2026
3585841
Strip ".statistics" suffix from the dataTypeName sent to polymerize
claude Aug 4, 2026
d8c6388
Query .statistics data types via sampleSet:dailyPolymerize, not polym…
claude Aug 4, 2026
da4ddfd
Fix activityRecords route against the official REST API reference
claude Aug 6, 2026
9c90c4e
Disable daily_activity_summary by default pending a redesign
claude Aug 6, 2026
89ea098
Query *.total routes by their raw/delta data type via dailyPolymerize
claude Aug 6, 2026
309b807
Fix continuous_spo2_statistics data type name and field keys
claude Aug 6, 2026
1bf0b9e
Fix daily_activity_summary, distance_total, altitude_statistics, acti…
claude Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ env:
'build_file': 'kafka-connect-oura-source/Dockerfile',
'authors': 'Pim van Nierop <pim@thehyve.nl>, Pauline Conde <pauline.conde@kcl.ac.uk>',
'description': 'RADAR-base Oura connector application'
},{
'name': 'kafka-connect-huawei-source',
'build_file': 'kafka-connect-huawei-source/Dockerfile',
'authors': 'Yatharth Ranjan <yatharth.ranjan@kcl.ac.uk>, Yatharth Ranjan <yatharthranjan@onsentia.com>',
'description': 'RADAR-base Huawei Health Kit connector application'
}]

jobs:
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ env:
},{
'name': 'kafka-connect-oura-source',
'build_file': 'kafka-connect-oura-source/Dockerfile',
'authors': 'Pauline Conde <pauline.conde@kcl.ac.uk>, Yatharth Ranjan <yatharth.ranjan@kcl.ac.uk>',
'authors': 'Pauline Conde <pauline.conde@kcl.ac.uk>, Yatharth Ranjan <yatharth.ranjan@kcl.ac.uk>, Yatharth Ranjan <yatharthranjan@onsentia.com>',
'description': 'RADAR-base Oura connector application'
},{
'name': 'kafka-connect-huawei-source',
'build_file': 'kafka-connect-huawei-source/Dockerfile',
'authors': 'Yatharth Ranjan <yatharth.ranjan@kcl.ac.uk>, Yatharth Ranjan <yatharthranjan@onsentia.com>',
'description': 'RADAR-base Huawei Health Kit connector application'
}]

jobs:
Expand Down
309 changes: 309 additions & 0 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

64 changes: 61 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# Kafka Connect REST Source and Fitbit Source

This project contains a Kafka Connect source connector for a general REST API, for
specific Fitbit and Oura devices. The documentation of the Kafka Connect REST source still needs to
be done.
specific Fitbit, Oura, and Huawei Health Kit devices. The documentation of the Kafka Connect REST
source still needs to be done.

<!-- TOC -->

* [Kafka Connect REST Source and Fitbit Source](#kafka-connect-rest-source-and-fitbit-source)
* [Fitbit source connector](#fitbit-source-connector)
* [Installation](#installation)
* [Usage](#usage)
* [Huawei Health Kit source connector](#huawei-health-kit-source-connector)
* [Sentry monitoring](#sentry-monitoring)
* [Contributing](#contributing)

Expand Down Expand Up @@ -208,9 +209,66 @@ sequenceDiagram
connector ->> connector: Update offset times
```

## Huawei Health Kit source connector

The `kafka-connect-huawei-source` module polls the
[Huawei Health Kit Data API](https://developer.huawei.com/consumer/en/doc/HMSCore-References/rest-overview-0000001254420693)
for the data types documented in the
[`radar-huawei-connector` schema specification](https://github.com/RADAR-base/RADAR-Schemas/blob/huawei_schemas/specifications/connector/radar-huawei-connector-1.0.0.yml)
(RADAR-Schemas, `huawei_schemas` branch) — activity records, continuous/instantaneous sample
statistics (steps, distance, calories, heart rate, SpO2, blood pressure, breathing rate, ECG,
sleep stages, and more), health records (ambulatory blood pressure, heart rate alerts,
hyperthermia, low SpO2 alerts, menstrual cycle, sleep), and daily summaries. It follows the same
`rest.source.*`, `huawei.api.client`/`huawei.api.secret`, and `huawei.user.repository.*`
configuration conventions as the Fitbit and Oura connectors above, plus one
`huawei.<data-type>.enabled` / `huawei.<data-type>.topic` pair per Huawei data type — see
`org.radarbase.huawei.route.HuaweiRouteFactory` for the full list of `<data-type>` keys and their
default topic names, and `docker/source-huawei.properties.template` for a minimal example.

This connector requires a
[published `radar-schemas-commons` build containing the `huawei_schemas` branch](https://github.com/RADAR-base/RADAR-Schemas/tree/huawei_schemas)
(currently `0.9.0-SNAPSHOT`) to be resolvable from one of the repositories declared in
`huawei-library/build.gradle` / `kafka-connect-huawei-source/build.gradle.kts`.

### Testing locally

The easiest way to try out the Huawei connector without standing up a
`rest-source-authorizer` webservice is the file-based
`org.radarbase.connect.rest.huawei.user.HuaweiYamlUserRepository`, which reads one YAML file per
user from a local directory, mirroring the Fitbit `YamlUserRepository` above.

1. [Register a Huawei Health Kit OAuth 2.0 app](https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/config-agc-0000001050170137)
and obtain an access token and refresh token for one test user by hand, using Huawei's
[OAuth 2.0 authorization code flow](https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/authorization-code-0000001053629189).
2. Copy `docker/huawei-user.yml.template` to a file in `docker/users/` (e.g. `docker/users/test.yml`)
and fill in the `externalUserId`, `oauth2.accessToken`, and `oauth2.refreshToken` fields.
3. Copy `docker/source-huawei.properties.template` to `docker/source-huawei.properties`, set
`huawei.api.client` / `huawei.api.secret` to your Huawei app's client ID and secret, and change
`huawei.user.repository.class` to `org.radarbase.connect.rest.huawei.user.HuaweiYamlUserRepository`
(the `huawei.user.repository.url`/`.client.id`/`.client.secret`/`.oauth2.token.url` properties
are only used by the webservice-based repository and can be left as-is or removed).
4. Run the full stack with `docker-compose up -d --build` and inspect the connector's progress with
`docker-compose logs -f radar-huawei-connector`.
5. To inspect the data coming out of a specific route, run, for example:

```shell
docker-compose exec schema-registry-1 kafka-avro-console-consumer \
--bootstrap-server kafka-1:9092,kafka-2:9092,kafka-3:9092 \
--from-beginning \
--topic connect_huawei_activity_record
```

(replace the topic with any `huawei.<data-type>.topic` default from
`org.radarbase.huawei.route.HuaweiRouteFactory`).

For a full RADAR-base deployment, use the webservice-based
`org.radarbase.connect.rest.huawei.user.HuaweiServiceUserRepository` (the default) against a
`rest-source-authorizer` instance instead, following the same pattern as the Fitbit connector's
ManagementPortal setup above.

## Sentry monitoring

To enable Sentry monitoring for the generic REST, Fitbit, or Oura source connector service:
To enable Sentry monitoring for the generic REST, Fitbit, Oura, or Huawei source connector service:

1. Set a `SENTRY_DSN` environment variable that points to the desired Sentry DSN.
2. (Optional) Set the `SENTRY_LOG_LEVEL` environment variable to control the minimum log level of
Expand Down
47 changes: 47 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version: "2.4"
volumes:
fitbit-logs: {}
oura-logs: {}
huawei-logs: {}

services:
#---------------------------------------------------------------------------#
Expand Down Expand Up @@ -231,3 +232,49 @@ services:
# SENTRY_DSN: 'https://000000000000.ingest.de.sentry.io/000000000000'
# SENTRY_ATTACHSTACKTRACE: true
# SENTRY_STACKTRACE_APP_PACKAGES: io.confluent.connect,org.radarbase.connect.rest

#---------------------------------------------------------------------------#
# RADAR Huawei connector #
#---------------------------------------------------------------------------#
radar-huawei-connector:
build:
context: .
dockerfile: ./kafka-connect-huawei-source/Dockerfile
image: radarbase/radar-connect-huawei-source
restart: on-failure
volumes:
- ./docker/source-huawei.properties:/etc/kafka-connect/source-huawei.properties
- ./docker/users:/var/lib/kafka-connect-huawei-source/users
- huawei-logs:/var/lib/kafka-connect-huawei-source/logs
depends_on:
- zookeeper-1
- zookeeper-2
- zookeeper-3
- kafka-1
- kafka-2
- kafka-3
- schema-registry-1
environment:
CONNECT_BOOTSTRAP_SERVERS: PLAINTEXT://kafka-1:9092,PLAINTEXT://kafka-2:9092,PLAINTEXT://kafka-3:9092
CONNECT_REST_PORT: 8083
CONNECT_GROUP_ID: "default"
CONNECT_CONFIG_STORAGE_TOPIC: "default.config"
CONNECT_OFFSET_STORAGE_TOPIC: "default.offsets"
CONNECT_STATUS_STORAGE_TOPIC: "default.status"
CONNECT_KEY_CONVERTER: "io.confluent.connect.avro.AvroConverter"
CONNECT_VALUE_CONVERTER: "io.confluent.connect.avro.AvroConverter"
CONNECT_KEY_CONVERTER_SCHEMA_REGISTRY_URL: "http://schema-registry-1:8081"
CONNECT_VALUE_CONVERTER_SCHEMA_REGISTRY_URL: "http://schema-registry-1:8081"
CONNECT_INTERNAL_KEY_CONVERTER: "org.apache.kafka.connect.json.JsonConverter"
CONNECT_INTERNAL_VALUE_CONVERTER: "org.apache.kafka.connect.json.JsonConverter"
CONNECT_OFFSET_STORAGE_FILE_FILENAME: "/var/lib/kafka-connect-huawei-source/logs/connect.offsets"
CONNECT_REST_ADVERTISED_HOST_NAME: "radar-huawei-connector"
CONNECT_ZOOKEEPER_CONNECT: zookeeper-1:2181,zookeeper-2:2181,zookeeper-3:2181
CONNECTOR_PROPERTY_FILE_PREFIX: "source-huawei"
KAFKA_HEAP_OPTS: "-Xms256m -Xmx768m"
KAFKA_BROKERS: 3
CONNECT_LOG4J_LOGGERS: "org.reflections=ERROR"
# SENTRY_LOG_LEVEL: 'ERROR'
# SENTRY_DSN: 'https://000000000000.ingest.de.sentry.io/000000000000'
# SENTRY_ATTACHSTACKTRACE: true
# SENTRY_STACKTRACE_APP_PACKAGES: io.confluent.connect,org.radarbase.connect.rest
24 changes: 24 additions & 0 deletions docker/huawei-user.yml.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
# Unique user key
id: test
# Project ID to be used in org.radarcns.kafka.ObservationKey record keys
projectId: radar-test
# User ID to be used in org.radarcns.kafka.ObservationKey record keys
userId: test
# Source ID to be used in org.radarcns.kafka.ObservationKey record keys
sourceId: huawei-watch
# Date from when to collect data.
startDate: 2018-08-06T00:00:00Z
# Date until when to collect data.
endDate: 2099-01-01T00:00:00Z
# Huawei user ID as returned by the Huawei OAuth 2.0 authentication procedure
externalUserId: ?
oauth2:
# Huawei Health Kit OAuth 2.0 access token as returned by the Huawei authentication procedure
accessToken: ?
# Huawei Health Kit OAuth 2.0 refresh token as returned by the Huawei authentication procedure
refreshToken: ?
# Optional expiry time of the access token. If absent, it will be estimated to one hour
# when the source connector starts. When an authentication error occurs, a new access token will
# be fetched regardless of the value in this field.
#expiresAt: 2018-08-06T00:00:00Z
18 changes: 18 additions & 0 deletions docker/source-huawei.properties.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name=radar-huawei-source
connector.class=org.radarbase.connect.rest.huawei.HuaweiSourceConnector
tasks.max=4
rest.source.base.url=https://health-api.cloud.huawei.com/healthkit/v1
rest.source.poll.interval.ms=5000
huawei.api.client=?
huawei.api.secret=?
# For local testing without a rest-source-authorizer webservice, use the file-based
# repository instead, backed by per-user YAML files under huawei.user.dir - see
# docker/huawei-user.yml.template and the "Testing locally" section in README.md.
#huawei.user.repository.class=org.radarbase.connect.rest.huawei.user.HuaweiYamlUserRepository
#huawei.user.dir=/var/lib/kafka-connect-huawei-source/users

huawei.user.repository.class=org.radarbase.connect.rest.huawei.user.HuaweiServiceUserRepository
huawei.user.repository.url=http://localhost:8080/
huawei.user.repository.client.id=radar_huawei_connector
huawei.user.repository.client.secret=
huawei.user.repository.oauth2.token.url=
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ sentryOpenTelemetryAgent = "8.36.0"
okhttp = "4.12.0"
firebaseAdmin = "9.8.0"
radarSchemas = "0.8.16"
# Huawei connector schemas are not yet released; pin to the published snapshot until a stable
# radar-schemas-commons release containing the huawei_schemas branch is cut.
radarSchemasHuawei = "0.9.0-SNAPSHOT"
# @pin Upgrade to 3.x.x requires kotlin v2 minimum
ktor = "2.3.13"
wiremock = "3.0.1"
Expand All @@ -28,6 +31,7 @@ lz4 = "1.10.1"
lz4 = { module = "at.yawk.lz4:lz4-java", version.ref = "lz4" }
radar-commons-kotlin = { module = "org.radarbase:radar-commons-kotlin", version.ref = "radarCommons" }
radar-schemas-commons = { module = "org.radarbase:radar-schemas-commons", version.ref = "radarSchemas" }
radar-schemas-commons-huawei = { module = "org.radarbase:radar-schemas-commons", version.ref = "radarSchemasHuawei" }
kafka-connect-api = "org.apache.kafka:connect-api:7.8.1-ce"
kafka-connect-avro-converter = { module = "io.confluent:kafka-connect-avro-converter", version.ref = "confluent" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
Expand Down Expand Up @@ -55,6 +59,7 @@ mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
wiremock = { module = "com.github.tomakehurst:wiremock", version.ref = "wiremock" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" }

[plugins]
radar-root-project = { id = "org.radarbase.radar-root-project", version.ref = "radarCommons" }
Expand Down
70 changes: 70 additions & 0 deletions huawei-library/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@

group = 'org.radarbase'
version = '0.0.1'

apply plugin: 'maven-publish'

repositories {
// Use jcenter for resolving dependencies.
// You can declare any Maven/Ivy/file repository here.
mavenCentral()

// Prefer a locally-published snapshot (e.g. built by hand from the RADAR-Schemas
// huawei_schemas branch via `./gradlew :radar-schemas-commons:publishToMavenLocal`) before
// falling back to remote snapshot hosts.
mavenLocal()

// radar-schemas-commons huawei_schemas is only published as a snapshot; declare the
// candidate snapshot hosts here so the huawei-library build can resolve it regardless of
// which one the RADAR-Schemas release pipeline currently targets.
maven {
url = uri("https://central.sonatype.com/repository/maven-snapshots/")
}
maven {
url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")
}
maven {
url = uri("https://maven.pkg.github.com/RADAR-base/RADAR-Schemas")
credentials {
username = project.findProperty("public.gpr.user") ?: System.getenv("GPR_USER")
password = project.findProperty("public.gpr.token") ?: System.getenv("GPR_TOKEN")
}
}
}

dependencies {
// Use the Kotlin JDK 8 standard library.
implementation libs.kotlin.stdlib

implementation libs.okhttp

implementation libs.radar.schemas.commons.huawei

implementation libs.jackson.annotations

implementation libs.jackson.databind

implementation libs.avro

implementation libs.jackson.datatype.jsr310

// Use the Kotlin test library.
testImplementation libs.kotlin.test

// Use the Kotlin JUnit 5 integration (matches the JUnit Platform the radar-kotlin
// Gradle plugin configures the `test` task with).
testImplementation libs.kotlin.test.junit5
}

project.afterEvaluate {
publishing {
publications {
library(MavenPublication) {
setGroupId "$group"
setArtifactId "huawei-library"
version "$version"
from components.java
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright 2026 Onsentia
*
* 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
*
* http://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.
*
*/

package org.radarbase.huawei.converter

import com.fasterxml.jackson.databind.JsonNode

/**
* Typed accessor for a single Huawei Health Kit sample point's field values.
*
* The Health Kit Data API (`sampleSet:polymerize`) represents each field of a sample point using
* the same typed-value wrapper as the on-device HiHealth SDK's `Field`/`Value` model: a list of
* objects shaped like `{"fieldName": "steps_delta", "integerValue": 123}` (or `floatValue`,
* `longValue`, `stringValue` depending on the field's declared type). This class also tolerates a
* flattened `{"fieldName": value, ...}` object, in case a particular endpoint or API version
* returns the simplified shape, so a single parser can be reused across all sample-set based
* routes.
*
* Field name constants follow Huawei's public `Field` identifiers (e.g. `steps_delta`, `calories`,
* `avg`, `max`, `min`), as documented for the on-device and REST Health Kit APIs.
*
* @author yatharthranjan
*/
class FieldValues private constructor(private val values: Map<String, JsonNode>) {

fun getInt(field: String): Int? = values[field]?.let { if (it.isNull) null else it.asInt() }

fun getLong(field: String): Long? = values[field]?.let { if (it.isNull) null else it.asLong() }

fun getDouble(field: String): Double? = values[field]?.let {
if (it.isNull) null else it.asDouble()
}

fun getFloat(field: String): Float? = getDouble(field)?.toFloat()

fun getString(field: String): String? = values[field]?.let {
if (it.isNull) null else it.asText()
}

companion object {
private const val FIELD_NAME_KEY = "fieldName"
private val VALUE_KEYS =
listOf("integerValue", "floatValue", "longValue", "stringValue", "value")

fun from(node: JsonNode?): FieldValues {
if (node == null || node.isMissingNode || node.isNull) {
return FieldValues(emptyMap())
}
if (node.isArray) {
val map = LinkedHashMap<String, JsonNode>()
node.forEach { entry ->
val name = entry.get(FIELD_NAME_KEY)?.asText() ?: return@forEach
val value = VALUE_KEYS.firstNotNullOfOrNull { key -> entry.get(key) }
if (value != null) {
map[name] = value
}
}
return FieldValues(map)
}
if (node.isObject) {
val map = LinkedHashMap<String, JsonNode>()
node.properties().forEach { (name, value) -> map[name] = value }
return FieldValues(map)
}
return FieldValues(emptyMap())
}
}
}
Loading
Loading