glimta 0.1.0

A local-first Rust client for the classic IKEA TRADFRI gateway
Documentation
use glimta::{
    Credentials, Device, DeviceKind, Group,
    command::{
        CommandError, Method, observe_device, provision_identity, set_air_purifier_auto,
        set_air_purifier_fan_speed, set_blind_position, set_group_brightness, set_group_state,
        set_light_brightness, set_light_state, set_socket_state,
    },
};

#[test]
fn parses_a_classic_tradfri_light_payload() {
    let raw = r#"{
        "9003": 65537,
        "9001": "Living room",
        "5750": 2,
        "9019": 1,
        "9020": 1700000000,
        "3": {
            "0": "IKEA of Sweden",
            "1": "TRADFRI bulb E27",
            "2": "deadbeef",
            "3": "1.2.3"
        },
        "3311": [{
            "9003": 0,
            "5850": 1,
            "5851": 128,
            "5711": 370
        }]
    }"#;

    let device: Device = serde_json::from_str(raw).expect("fixture should deserialize");

    assert_eq!(device.id, 65537);
    assert_eq!(device.name(), "Living room");
    assert_eq!(device.kind(), DeviceKind::Light);
    assert_eq!(device.is_reachable(), Some(true));
    assert_eq!(device.info.manufacturer.as_deref(), Some("IKEA of Sweden"));

    let light = device.primary_light().expect("light should exist");
    assert!(light.is_on());
    assert_eq!(light.brightness, Some(128));
    assert_eq!(light.color_temperature_mireds, Some(370));
}

#[test]
fn parses_multiple_device_capabilities_and_preserves_unknown_fields() {
    let raw = r#"{
        "9003": 42,
        "9001": "Mixed fixture",
        "3": {"0": "IKEA of Sweden", "1": "fixture", "7777": "future-info"},
        "3312": [{"9003": 0, "5850": 1}],
        "15015": [{"9003": 1, "5536": 47.5}],
        "15025": [{
            "9003": 2,
            "5900": 1,
            "5905": 1,
            "5906": 0,
            "5907": 23,
            "5908": 17,
            "5903": 0
        }],
        "9999": {"unknown": true}
    }"#;

    let device: Device = serde_json::from_str(raw).expect("fixture should deserialize");
    assert_eq!(
        device.capabilities(),
        vec![
            DeviceKind::Socket,
            DeviceKind::Blind,
            DeviceKind::AirPurifier
        ]
    );
    assert!(device.sockets[0].is_on());
    assert!((device.blinds[0].current_position - 47.5).abs() < f64::EPSILON);
    assert_eq!(device.air_purifiers[0].is_auto_mode(), Some(true));
    assert_eq!(device.air_purifiers[0].controls_locked(), Some(true));
    assert_eq!(device.air_purifiers[0].leds_off(), Some(false));
    assert!(device.extra.contains_key("9999"));
    assert!(device.info.extra.contains_key("7777"));
}

#[test]
fn parses_group_members() {
    let raw = r#"{
        "9003": 12,
        "9001": "Downstairs",
        "5850": 1,
        "5851": 180,
        "9018": {"15002": {"9003": [65536, 65537]}}
    }"#;

    let group: Group = serde_json::from_str(raw).expect("group fixture should deserialize");
    assert!(group.is_on());
    assert_eq!(group.member_ids(), vec![65536, 65537]);
}

#[test]
fn builds_the_gateway_identity_provisioning_request() {
    let command = provision_identity("glimta-test");

    assert_eq!(command.method, Method::Post);
    assert_eq!(command.path, "15011/9063");
    assert_eq!(command.body.expect("body")["9090"], "glimta-test");
}

#[test]
fn builds_light_commands_using_the_gateway_wire_shape() {
    let command = set_light_state(65537, true);

    assert_eq!(command.method, Method::Put);
    assert_eq!(command.path, "15001/65537");
    assert_eq!(command.body.expect("body")["3311"][0]["5850"], 1);

    let command = set_light_brightness(65537, 42, Some(10)).expect("valid brightness");
    let body = command.body.expect("body");
    assert_eq!(body["3311"][0]["5851"], 42);
    assert_eq!(body["3311"][0]["5712"], 10);
}

#[test]
fn builds_socket_blind_purifier_and_group_commands() {
    let socket = set_socket_state(7, true).body.expect("socket body");
    assert_eq!(socket["3312"][0]["5850"], 1);

    let blind = set_blind_position(8, 75)
        .expect("valid position")
        .body
        .expect("blind body");
    assert_eq!(blind["15015"][0]["5536"], 75);

    let purifier = set_air_purifier_auto(9).body.expect("purifier body");
    assert_eq!(purifier["15025"][0]["5900"], 1);

    let group = set_group_state(10, false).body.expect("group body");
    assert_eq!(group["5850"], 0);

    let group = set_group_brightness(10, 99, Some(5))
        .expect("valid group brightness")
        .body
        .expect("group brightness body");
    assert_eq!(group["5851"], 99);
    assert_eq!(group["5712"], 5);
}

#[test]
fn validates_device_ranges_before_network_io() {
    assert_eq!(
        set_light_brightness(65537, 255, None),
        Err(CommandError::Brightness(255))
    );
    assert_eq!(
        set_blind_position(1, 101),
        Err(CommandError::BlindPosition(101))
    );
    assert_eq!(
        set_air_purifier_fan_speed(1, 51),
        Err(CommandError::AirPurifierFanSpeed(51))
    );
}

#[test]
fn observation_is_a_property_of_the_command_not_the_model() {
    let command = observe_device(65537);
    assert_eq!(command.method, Method::Get);
    assert!(command.observe);
}

#[test]
fn credentials_never_debug_the_secret() {
    let credentials = Credentials::new("client", "super-secret").expect("valid credentials");
    let debug = format!("{credentials:?}");
    assert!(debug.contains("client"));
    assert!(debug.contains("<redacted>"));
    assert!(!debug.contains("super-secret"));
}