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 string `json:"id"` Token string `json:"terminal_user_session_key"` Email string `json:"email"` } // Table mapping type tableMapping struct { UserID string `json:"user_id"` TableName string `json:"table_name"` } // Measurement from Renpho type measurement struct { TimeStamp int64 `json:"time_stamp"` 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:"fat_free_weight"` Sinew float64 `json:"sinew"` UserID string `json:"internal_model"` } // 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") flag.Parse() if err := lib.Init(); err != nil { fatal("lib.Init: %v", err) } if *setup { runSetup() return } if *discover { runDiscover() 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) } // runDiscover logs into Renpho and shows user IDs + table mappings func runDiscover() { if flag.NArg() < 2 { fmt.Println("Usage: import-renpho -discover ") 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) tables, err := getTableMappings(s) if err != nil { fatal("table mappings: %v", err) } fmt.Println("\nUser → Table mappings:") for _, t := range tables { fmt.Printf(" user_id: %s table: %s\n", t.UserID, t.TableName) // Fetch a sample measurement to show what user this is ms, err := fetchMeasurements(s, t.UserID, 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) // Get table mappings tables, err := getTableMappings(s) if err != nil { return fmt.Errorf("table mappings: %v", err) } for _, t := range tables { dossierID := acct.DossierID if acct.UserMap != nil { if mapped, ok := acct.UserMap[t.UserID]; ok { dossierID = mapped } } if dossierID == "" { fmt.Printf(" Skipping user %s (no dossier mapped)\n", t.UserID) 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, t.UserID, t.TableName) if err != nil { fmt.Printf(" Table %s: %v\n", t.TableName, 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") }