inou/import-renpho/main.go

648 lines
18 KiB
Go

package main
import (
"bytes"
"crypto/aes"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"inou/lib"
)
const (
baseURL = "https://cloud.renpho.com"
aesKey = "ed*wijdi$h6fe3ew"
)
// Renpho API response
type apiResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
// Login response
type loginUser struct {
UserID json.Number `json:"id"`
Token string `json:"token"`
Email string `json:"email"`
}
// Table mapping
type tableMapping struct {
UserIDs []json.Number `json:"userIds"`
TableName string `json:"tableName"`
Count int `json:"count"`
}
// Measurement from Renpho
type measurement struct {
TimeStamp int64 `json:"timeStamp"`
Weight float64 `json:"weight"`
BodyFat float64 `json:"bodyfat"`
Water float64 `json:"water"`
BMR float64 `json:"bmr"`
BodyAge float64 `json:"bodyage"`
Muscle float64 `json:"muscle"`
Bone float64 `json:"bone"`
SubFat float64 `json:"subfat"`
VisFat float64 `json:"visfat"`
BMI float64 `json:"bmi"`
Protein float64 `json:"protein"`
FatFree float64 `json:"fatFreeWeight"`
Sinew float64 `json:"sinew"`
BUserID json.Number `json:"bUserId"`
}
// Account config stored in Renpho dossier's Data field
type renphoConfig struct {
Accounts []renphoAccount `json:"renpho_accounts"`
}
type renphoAccount struct {
Email string `json:"email"`
Password string `json:"password"`
DossierID string `json:"dossier_id"` // inou dossier to write vitals to
UserMap map[string]string `json:"user_map"` // renpho userID → inou dossierID (for multi-user scales)
}
// Runtime state for an authenticated session
type session struct {
token string
}
func main() {
setup := flag.Bool("setup", false, "Create Renpho system dossier and configure accounts")
discover := flag.Bool("discover", false, "Login and show Renpho user IDs for mapping")
fileImport := flag.String("file", "", "Import from JSON file instead of API (format: measurements array)")
dossierID := flag.String("dossier", "", "Target dossier ID (required with -file)")
flag.Parse()
if err := lib.Init(); err != nil {
fatal("lib.Init: %v", err)
}
if *setup {
runSetup()
return
}
if *discover {
runDiscover()
return
}
if *fileImport != "" {
if *dossierID == "" {
fatal("-dossier required with -file")
}
runFileImport(*fileImport, *dossierID)
return
}
renphoID, cfg, err := loadConfig()
if err != nil {
fatal("load config: %v", err)
}
if len(cfg.Accounts) == 0 {
fmt.Println("No Renpho accounts configured. Run with -setup first.")
return
}
importID := lib.NextImportID()
for _, acct := range cfg.Accounts {
fmt.Printf("=== %s ===\n", acct.Email)
if err := syncAccount(renphoID, &acct, importID); err != nil {
fmt.Printf(" ERROR: %v\n", err)
}
}
}
// runSetup creates the Renpho system dossier with config from stdin
func runSetup() {
// Check if already exists
entries, err := lib.EntryRead("", "", &lib.Filter{Category: 0})
if err != nil {
fatal("list dossiers: %v", err)
}
for _, e := range entries {
if strings.EqualFold(e.SearchKey, "renpho") {
fmt.Printf("Renpho dossier already exists: %s\n", e.EntryID)
fmt.Println("Current config:")
fmt.Println(e.Data)
return
}
}
// Read config JSON from stdin
fmt.Println("Paste renpho_accounts JSON (or empty for blank), then Ctrl+D:")
data, _ := io.ReadAll(os.Stdin)
configJSON := strings.TrimSpace(string(data))
// Validate if non-empty
if configJSON != "" {
var cfg renphoConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
fatal("invalid JSON: %v", err)
}
} else {
configJSON = `{"renpho_accounts":[]}`
}
id := lib.NewID()
err = lib.EntryWrite("", &lib.Entry{
EntryID: id,
DossierID: id,
Category: 0,
Type: "dossier",
Summary: "Renpho",
SearchKey: "renpho",
Data: configJSON,
Timestamp: time.Now().Unix(),
})
if err != nil {
fatal("create dossier: %v", err)
}
fmt.Printf("Created Renpho dossier: %s\n", id)
}
// runFileImport imports measurements from a JSON file (offline mode)
func runFileImport(filePath, dossierID string) {
data, err := os.ReadFile(filePath)
if err != nil {
fatal("read file: %v", err)
}
var ms []measurement
if err := json.Unmarshal(data, &ms); err != nil {
fatal("parse JSON: %v", err)
}
fmt.Printf("Loaded %d measurements from %s\n", len(ms), filePath)
importID := lib.NextImportID()
// Use system accessor (empty string) for file imports
created, skipped, err := writeMeasurements("", dossierID, ms, importID)
if err != nil {
fatal("write: %v", err)
}
fmt.Printf("Created %d, skipped %d\n", created, skipped)
}
// runDiscover logs into Renpho and shows user IDs + table mappings
func runDiscover() {
if flag.NArg() < 2 {
fmt.Println("Usage: import-renpho -discover <email> <password>")
os.Exit(1)
}
email, password := flag.Arg(0), flag.Arg(1)
s, user, err := login(email, password)
if err != nil {
fatal("login: %v", err)
}
fmt.Printf("Logged in: %s (user ID: %s)\n", user.Email, user.UserID.String())
tables, err := getTableMappings(s)
if err != nil {
fatal("table mappings: %v", err)
}
fmt.Println("\nUser → Table mappings:")
for _, t := range tables {
for _, uid := range t.UserIDs {
fmt.Printf(" user_id: %s table: %s\n", uid.String(), t.TableName)
ms, err := fetchMeasurements(s, uid.String(), t.TableName)
if err == nil && len(ms) > 0 {
fmt.Printf(" %d measurements, latest weight: %.1f kg\n", len(ms), ms[0].Weight)
}
}
}
}
// loadConfig reads the Renpho system dossier and its config from Data field
func loadConfig() (string, *renphoConfig, error) {
// Find Renpho dossier by searching for email="renpho" in cat=0
entries, err := lib.EntryRead("", "", &lib.Filter{Category: 0})
if err != nil {
return "", nil, fmt.Errorf("list dossiers: %v", err)
}
for _, e := range entries {
if strings.EqualFold(e.SearchKey, "renpho") {
var cfg renphoConfig
if e.Data != "" {
if err := json.Unmarshal([]byte(e.Data), &cfg); err != nil {
return "", nil, fmt.Errorf("parse config: %v", err)
}
}
return e.EntryID, &cfg, nil
}
}
return "", nil, fmt.Errorf("no Renpho system dossier found (create a dossier with SearchKey='renpho')")
}
func syncAccount(renphoID string, acct *renphoAccount, importID int64) error {
// Login
s, user, err := login(acct.Email, acct.Password)
if err != nil {
return fmt.Errorf("login: %v", err)
}
fmt.Printf(" Logged in as %s (user %s)\n", user.Email, user.UserID.String())
// Get table mappings
tables, err := getTableMappings(s)
if err != nil {
return fmt.Errorf("table mappings: %v", err)
}
for _, t := range tables {
for _, uid := range t.UserIDs {
uidStr := uid.String()
dossierID := acct.DossierID
if acct.UserMap != nil {
if mapped, ok := acct.UserMap[uidStr]; ok {
dossierID = mapped
}
}
if dossierID == "" {
fmt.Printf(" Skipping user %s (no dossier mapped)\n", uidStr)
continue
}
// Ensure Renpho has write access to this dossier
if !lib.CheckAccess(renphoID, dossierID, "", lib.PermWrite) {
fmt.Printf(" Granting Renpho access to %s\n", dossierID)
if err := lib.GrantAccess(dossierID, renphoID, dossierID, lib.PermRead|lib.PermWrite, 0); err != nil {
return fmt.Errorf("grant access to %s: %v", dossierID, err)
}
}
measurements, err := fetchMeasurements(s, uidStr, t.TableName)
if err != nil {
fmt.Printf(" Table %s user %s: %v\n", t.TableName, uidStr, err)
continue
}
fmt.Printf(" Table %s: %d measurements for dossier %s\n", t.TableName, len(measurements), dossierID)
created, skipped, err := writeMeasurements(renphoID, dossierID, measurements, importID)
if err != nil {
fmt.Printf(" Write error: %v\n", err)
continue
}
fmt.Printf(" Created %d, skipped %d\n", created, skipped)
}
}
return nil
}
func login(email, password string) (*session, *loginUser, error) {
body := map[string]string{"email": email, "password": password}
resp, err := apiCall(nil, "RenphoHealth/centerUser/loginUser", body)
if err != nil {
return nil, nil, err
}
if resp.Code != 101 {
return nil, nil, fmt.Errorf("code %d: %s", resp.Code, resp.Msg)
}
var user loginUser
if err := json.Unmarshal(resp.Data, &user); err != nil {
return nil, nil, fmt.Errorf("parse login: %v", err)
}
return &session{token: user.Token}, &user, nil
}
func getTableMappings(s *session) ([]tableMapping, error) {
resp, err := apiCall(s, "RenphoHealth/scale/getUserIdMappingTableNames", map[string]string{})
if err != nil {
return nil, err
}
if resp.Code != 101 {
return nil, fmt.Errorf("code %d: %s", resp.Code, resp.Msg)
}
var tables []tableMapping
if err := json.Unmarshal(resp.Data, &tables); err != nil {
return nil, err
}
return tables, nil
}
func fetchMeasurements(s *session, userID, tableName string) ([]measurement, error) {
userIDs, _ := json.Marshal([]string{userID})
body := map[string]any{
"pageNum": 1,
"pageSize": "200",
"userIds": string(userIDs),
"tableName": tableName,
}
resp, err := apiCall(s, "RenphoHealth/scale/queryAllMeasureDataList/", body)
if err != nil {
return nil, err
}
if resp.Code != 101 {
return nil, fmt.Errorf("code %d: %s", resp.Code, resp.Msg)
}
var ms []measurement
if err := json.Unmarshal(resp.Data, &ms); err != nil {
return nil, err
}
return ms, nil
}
// writeMeasurements converts Renpho data to inou vital entries
func writeMeasurements(renphoID, dossierID string, ms []measurement, importID int64) (int, int, error) {
created, skipped := 0, 0
// Ensure category root
rootID, err := ensureRoot(renphoID, dossierID)
if err != nil {
return 0, 0, err
}
// Metric definitions: field name → (unit, label)
type metricDef struct {
unit string
label string
}
metrics := []struct {
name string
def metricDef
get func(m *measurement) float64
}{
{"weight", metricDef{"kg", "Weight"}, func(m *measurement) float64 { return m.Weight }},
{"body_fat", metricDef{"%", "Body Fat"}, func(m *measurement) float64 { return m.BodyFat }},
{"water", metricDef{"%", "Water"}, func(m *measurement) float64 { return m.Water }},
{"bmr", metricDef{"kcal", "BMR"}, func(m *measurement) float64 { return m.BMR }},
{"body_age", metricDef{"years", "Body Age"}, func(m *measurement) float64 { return m.BodyAge }},
{"muscle", metricDef{"%", "Muscle"}, func(m *measurement) float64 { return m.Muscle }},
{"bone", metricDef{"kg", "Bone Mass"}, func(m *measurement) float64 { return m.Bone }},
{"subcutaneous_fat", metricDef{"%", "Subcutaneous Fat"}, func(m *measurement) float64 { return m.SubFat }},
{"visceral_fat", metricDef{"", "Visceral Fat"}, func(m *measurement) float64 { return m.VisFat }},
{"bmi", metricDef{"", "BMI"}, func(m *measurement) float64 { return m.BMI }},
{"protein", metricDef{"%", "Protein"}, func(m *measurement) float64 { return m.Protein }},
{"fat_free_weight", metricDef{"kg", "Fat Free Weight"}, func(m *measurement) float64 { return m.FatFree }},
{"skeletal_muscle", metricDef{"%", "Skeletal Muscle"}, func(m *measurement) float64 { return m.Sinew }},
}
groups := map[string]string{} // metric name → groupID
for _, m := range ms {
ts := m.TimeStamp
if ts == 0 {
continue
}
// Renpho timestamps are in seconds
if ts > 1e12 {
ts = ts / 1000 // convert millis to seconds if needed
}
for _, met := range metrics {
val := met.get(&m)
if val == 0 {
continue
}
// Ensure group
groupID, ok := groups[met.name]
if !ok {
groupID, err = ensureGroup(renphoID, dossierID, rootID, met.name, met.def.unit, met.def.label)
if err != nil {
return created, skipped, fmt.Errorf("group %s: %v", met.name, err)
}
groups[met.name] = groupID
}
// Dedup: check for existing reading at same timestamp in same group
existing, _ := lib.EntryRead(renphoID, dossierID, &lib.Filter{
Category: lib.CategoryVital,
Type: "reading",
ParentID: groupID,
FromDate: ts,
ToDate: ts + 1,
})
if len(existing) > 0 {
skipped++
continue
}
valueStr := fmt.Sprintf("%g", val)
summary := valueStr
if met.def.unit != "" {
summary += " " + met.def.unit
}
data, _ := json.Marshal(map[string]string{"unit": met.def.unit, "source": "renpho"})
reading := &lib.Entry{
DossierID: dossierID,
ParentID: groupID,
Category: lib.CategoryVital,
Type: "reading",
Value: valueStr,
Summary: summary,
Timestamp: ts,
Data: string(data),
Import: importID,
}
if err := lib.EntryWrite(renphoID, reading); err != nil {
return created, skipped, fmt.Errorf("write %s@%d: %v", met.name, ts, err)
}
created++
}
}
return created, skipped, nil
}
func ensureRoot(accessorID, dossierID string) (string, error) {
entries, err := lib.EntryRead(accessorID, dossierID, &lib.Filter{
Category: lib.CategoryVital,
Type: "root",
ParentID: dossierID,
})
if err != nil {
return "", err
}
if len(entries) > 0 {
return entries[0].EntryID, nil
}
id := deterministicID(dossierID, "cat", fmt.Sprintf("%d", lib.CategoryVital))
err = lib.EntryWrite(accessorID, &lib.Entry{
EntryID: id,
DossierID: dossierID,
ParentID: dossierID,
Category: lib.CategoryVital,
Type: "root",
})
return id, err
}
func ensureGroup(accessorID, dossierID, rootID, metric, unit, label string) (string, error) {
entries, err := lib.EntryRead(accessorID, dossierID, &lib.Filter{
Category: lib.CategoryVital,
Type: metric,
ParentID: rootID,
})
if err != nil {
return "", err
}
if len(entries) > 0 {
return entries[0].EntryID, nil
}
id := deterministicID(dossierID, "group", fmt.Sprintf("%d", lib.CategoryVital), metric)
data, _ := json.Marshal(map[string]string{"unit": unit, "source": "renpho"})
err = lib.EntryWrite(accessorID, &lib.Entry{
EntryID: id,
DossierID: dossierID,
ParentID: rootID,
Category: lib.CategoryVital,
Type: metric,
Summary: label,
Data: string(data),
})
return id, err
}
func deterministicID(parts ...string) string {
h := sha256.Sum256([]byte(strings.Join(parts, ":")))
return fmt.Sprintf("%016x", h[:8])
}
// ============================================================================
// Renpho API client
// ============================================================================
func apiCall(s *session, path string, body any) (*apiResponse, error) {
bodyJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
encrypted := aesEncrypt(bodyJSON)
wrapper, _ := json.Marshal(map[string]string{"encryptData": encrypted})
req, err := http.NewRequest("POST", baseURL+"/"+path, bytes.NewReader(wrapper))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("timeZone", "+1")
req.Header.Set("language", "2")
req.Header.Set("appVersion", "5.18.3")
req.Header.Set("platform", "2")
req.Header.Set("systemVersion", "14")
req.Header.Set("phone", "import-renpho")
req.Header.Set("languageCode", "en")
req.Header.Set("area", "0")
if s != nil {
req.Header.Set("token", s.token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
}
// Outer response is plain JSON with encrypted data field
var outer struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(data, &outer); err != nil {
return nil, fmt.Errorf("parse response: %v (%s)", err, string(data))
}
result := &apiResponse{Code: outer.Code, Msg: outer.Msg}
// Data field is a quoted base64 AES-encrypted string
if len(outer.Data) > 2 && outer.Data[0] == '"' {
var encStr string
json.Unmarshal(outer.Data, &encStr)
if encStr != "" {
decrypted := aesDecrypt(encStr)
if decrypted != nil {
result.Data = decrypted
}
}
} else {
result.Data = outer.Data
}
return result, nil
}
// ============================================================================
// AES-128/ECB/PKCS5 — matches Renpho's com.renpho.module.utils.AESUtil
// ============================================================================
func aesEncrypt(plaintext []byte) string {
block, _ := aes.NewCipher([]byte(aesKey))
padded := pkcs5Pad(plaintext, aes.BlockSize)
out := make([]byte, len(padded))
for i := 0; i < len(padded); i += aes.BlockSize {
block.Encrypt(out[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
}
return base64.StdEncoding.EncodeToString(out)
}
func aesDecrypt(b64 string) []byte {
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil
}
block, err := aes.NewCipher([]byte(aesKey))
if err != nil {
return nil
}
if len(data)%aes.BlockSize != 0 {
return nil
}
out := make([]byte, len(data))
for i := 0; i < len(data); i += aes.BlockSize {
block.Decrypt(out[i:i+aes.BlockSize], data[i:i+aes.BlockSize])
}
return pkcs5Unpad(out)
}
func pkcs5Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
pad := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, pad...)
}
func pkcs5Unpad(data []byte) []byte {
if len(data) == 0 {
return nil
}
padding := int(data[len(data)-1])
if padding > len(data) || padding > aes.BlockSize {
return data
}
return data[:len(data)-padding]
}
func fatal(format string, args ...any) {
fmt.Printf("FATAL: "+format+"\n", args...)
panic("abort")
}