Skip to content

Commit 78477a8

Browse files
committed
fix: Blockly device_property dropdown shows all device properties
When a device has no Thing Description (or one without a 'properties' object), the /rules/discovery endpoint now falls back to inferring properties from the device's ShadowState (actual telemetry keys received from the device). Previously only 'Connection Status' appeared for non-greenhouse device types. - discovery.go: inferPropertiesFromState() infers property names and types (number/boolean/string) from ShadowState keys when TD has no properties. - handlers_test.go: TestDiscoverDevices_ShadowStateFallback covers the fallback path including type inference for float64 (number) and bool (boolean).
1 parent 45f654f commit 78477a8

2 files changed

Lines changed: 82 additions & 0 deletions

File tree

internal/api/rules/discovery.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ func (h *Handler) DiscoverDevices(c *gin.Context) {
5858
for _, dev := range devices {
5959
props := extractProperties(dev.ThingDescription)
6060

61+
// Fallback: if Thing Description has no properties, infer from ShadowState.
62+
// This covers devices registered without a TD but that have sent telemetry.
63+
if len(props) == 0 {
64+
props = inferPropertiesFromState(dev.ShadowState)
65+
}
66+
6167
// Add basic common properties if not present
6268
hasStatus := false
6369
for _, p := range props {
@@ -128,6 +134,31 @@ func extractProperties(td map[string]interface{}) []DevicePropertyInfo {
128134
return result
129135
}
130136

137+
// inferPropertiesFromState creates property definitions from a device's shadow state.
138+
// Keys become property names; Go types are mapped to WoT types (number/boolean/string).
139+
func inferPropertiesFromState(state map[string]interface{}) []DevicePropertyInfo {
140+
if len(state) == 0 {
141+
return nil
142+
}
143+
result := make([]DevicePropertyInfo, 0, len(state))
144+
for key, val := range state {
145+
propType := "string"
146+
switch val.(type) {
147+
case float64, int64:
148+
propType = "number"
149+
case bool:
150+
propType = "boolean"
151+
}
152+
result = append(result, DevicePropertyInfo{
153+
Key: key,
154+
Title: key,
155+
Type: propType,
156+
ReadOnly: true,
157+
})
158+
}
159+
return result
160+
}
161+
131162
// getStringField safely extracts a string field from a map.
132163
func getStringField(m map[string]interface{}, key, fallback string) string {
133164
if v, ok := m[key].(string); ok {

internal/api/rules/handlers_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,57 @@ func TestDiscoverDevices_WithDevices(t *testing.T) {
394394
}
395395
}
396396

397+
// TestDiscoverDevices_ShadowStateFallback verifies that when a device has no
398+
// ThingDescription, properties are inferred from its ShadowState telemetry keys.
399+
func TestDiscoverDevices_ShadowStateFallback(t *testing.T) {
400+
store := newTestStore()
401+
store.userDevices = []storage.Device{
402+
{
403+
ID: "custom-sensor",
404+
Name: "Custom Sensor",
405+
Type: "custom",
406+
ShadowState: map[string]interface{}{
407+
"temperature": 22.5,
408+
"humidity": 65.0,
409+
"active": true,
410+
},
411+
},
412+
}
413+
r := setupTestRouterWithUserAndStore(store, "user-1")
414+
415+
w := httptest.NewRecorder()
416+
req, _ := http.NewRequest("GET", "/admin/rules/discovery", nil)
417+
r.ServeHTTP(w, req)
418+
419+
if w.Code != http.StatusOK {
420+
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
421+
}
422+
423+
var result map[string]interface{}
424+
json.Unmarshal(w.Body.Bytes(), &result)
425+
devs := result["devices"].([]interface{})
426+
dev := devs[0].(map[string]interface{})
427+
props := dev["properties"].([]interface{})
428+
429+
// Should have 3 shadow keys + 1 status fallback = 4
430+
if len(props) < 4 {
431+
t.Fatalf("expected at least 4 properties (3 shadow + status), got %d: %v", len(props), props)
432+
}
433+
434+
// Verify type inference: temperature and humidity should be "number"
435+
propMap := make(map[string]string)
436+
for _, p := range props {
437+
pm := p.(map[string]interface{})
438+
propMap[pm["key"].(string)] = pm["type"].(string)
439+
}
440+
if propMap["temperature"] != "number" {
441+
t.Errorf("temperature type: want number, got %s", propMap["temperature"])
442+
}
443+
if propMap["active"] != "boolean" {
444+
t.Errorf("active type: want boolean, got %s", propMap["active"])
445+
}
446+
}
447+
397448
func TestDiscoverDevices_Empty(t *testing.T) {
398449
store := newTestStore() // no devices
399450
r := setupTestRouterWithUserAndStore(store, "user-xyz")

0 commit comments

Comments
 (0)