v0.15.6
This commit is contained in:
+5
-5
@@ -3,20 +3,19 @@ use crate::{
|
||||
error::AppError,
|
||||
home_assistant, influxdb,
|
||||
models::{
|
||||
ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport,
|
||||
ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup,
|
||||
AddDiscoveredDevicesRequest, ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup,
|
||||
ConfigurationExport, ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup,
|
||||
DeviceGroupKind, DevicePatch, DiscoveryRequest, EnergyReading, EnergySourcePreference,
|
||||
Flow, GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView, GreeSettings,
|
||||
GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings,
|
||||
HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings,
|
||||
InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NetworkReading,
|
||||
InfluxDbSettingsUpdate, InfluxDbSettingsView, LocalDiscoveryCandidate, ManualDeviceRequest, NetworkReading,
|
||||
NightModeSettings, NotificationSettings, NotificationSettingsUpdate,
|
||||
NotificationSettingsView, Reading, RuntimeSettings, Schedule, SettingsSnapshot,
|
||||
TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, ZoneControlPatch,
|
||||
ZoneReading,
|
||||
},
|
||||
notifications,
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
@@ -87,7 +86,8 @@ pub fn router(state: AppState) -> Router {
|
||||
let protected = Router::new()
|
||||
.route("/api/bootstrap", get(bootstrap))
|
||||
.route("/api/system/info", get(system_info))
|
||||
.route("/api/discovery", post(discover))
|
||||
.route("/api/discovery/scan", post(scan_discovery))
|
||||
.route("/api/discovery/add", post(add_discovered_devices))
|
||||
.route("/api/devices", get(list_devices).post(add_device))
|
||||
.route(
|
||||
"/api/devices/{id}",
|
||||
|
||||
+226
-51
@@ -1,15 +1,130 @@
|
||||
async fn discover(
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<DiscoveryRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
fn normalize_local_discovery_mac(value: &str) -> String {
|
||||
value.replace([':', '-'], "").trim().to_ascii_uppercase()
|
||||
}
|
||||
|
||||
fn local_discovery_candidate(device: &Device, already_added: bool) -> LocalDiscoveryCandidate {
|
||||
LocalDiscoveryCandidate {
|
||||
name: device.name.clone(),
|
||||
mac: device.mac.clone(),
|
||||
ip: device.ip.clone(),
|
||||
port: device.port,
|
||||
protocol_version: device.protocol_version,
|
||||
model: device.model.clone(),
|
||||
firmware: device.firmware.clone(),
|
||||
already_added,
|
||||
}
|
||||
}
|
||||
|
||||
fn device_from_local_discovery(candidate: LocalDiscoveryCandidate) -> Result<Device, AppError> {
|
||||
let mac = normalize_local_discovery_mac(&candidate.mac);
|
||||
if mac.is_empty() {
|
||||
return Err(AppError::BadRequest("discovered device MAC is required".into()));
|
||||
}
|
||||
candidate
|
||||
.ip
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| AppError::BadRequest(format!("invalid IP address for {mac}")))?;
|
||||
if !matches!(candidate.protocol_version, 1 | 2) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"invalid protocol version for {mac}"
|
||||
)));
|
||||
}
|
||||
|
||||
let model = candidate.model.trim().to_string();
|
||||
let fallback_model = if model.is_empty() { "GREE" } else { &model };
|
||||
let suffix = mac
|
||||
.chars()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
let name = if candidate.name.trim().is_empty() {
|
||||
format!("{fallback_model} {suffix}")
|
||||
} else {
|
||||
candidate.name.trim().to_string()
|
||||
};
|
||||
let now = Utc::now();
|
||||
|
||||
Ok(Device {
|
||||
id: format!("gree-{}", mac.to_ascii_lowercase()),
|
||||
mac,
|
||||
name,
|
||||
connection_type: ConnectionType::Local,
|
||||
connection_status: ConnectionStatus::Unknown,
|
||||
cloud_device_id: None,
|
||||
cloud_parent_mac: None,
|
||||
cloud_account_id: None,
|
||||
ip: candidate.ip,
|
||||
port: if candidate.port == 0 { 7000 } else { candidate.port },
|
||||
protocol_version: candidate.protocol_version,
|
||||
model,
|
||||
firmware: candidate.firmware.trim().to_string(),
|
||||
key: None,
|
||||
cid: Some("app".into()),
|
||||
enabled: true,
|
||||
simulated: false,
|
||||
power: false,
|
||||
mode: "cool".into(),
|
||||
target_temperature: 24.0,
|
||||
fan_speed: 0,
|
||||
swing_vertical: 0,
|
||||
swing_horizontal: 0,
|
||||
quiet: false,
|
||||
quiet_wire_value: None,
|
||||
turbo: false,
|
||||
light: true,
|
||||
air: false,
|
||||
xfan: false,
|
||||
health: false,
|
||||
sleep: false,
|
||||
supports_light: None,
|
||||
supports_quiet: None,
|
||||
supports_turbo: None,
|
||||
supports_air: None,
|
||||
supports_xfan: None,
|
||||
supports_health: None,
|
||||
supports_sleep: None,
|
||||
supports_buzzer_control: None,
|
||||
supports_energy_meter: None,
|
||||
total_energy_kwh: None,
|
||||
compressor_frequency_hz: None,
|
||||
last_cloud_sync: None,
|
||||
current_temperature: None,
|
||||
outdoor_temperature: None,
|
||||
temperature_sensor_offset: None,
|
||||
online: true,
|
||||
response_time_ms: None,
|
||||
last_seen: Some(now),
|
||||
last_error: None,
|
||||
communication_failures: 0,
|
||||
pending_command: false,
|
||||
capabilities: crate::models::DeviceCapabilities::default(),
|
||||
energy_source: EnergySourcePreference::Auto,
|
||||
ha_energy_entity_id: None,
|
||||
ha_energy_unit: None,
|
||||
ha_energy_device_class: None,
|
||||
ha_energy_state_class: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_local_discovery(
|
||||
state: &AppState,
|
||||
request: DiscoveryRequest,
|
||||
) -> Result<(u8, u8, Vec<Device>), AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let timeout_ms = request
|
||||
.timeout_ms
|
||||
.unwrap_or(settings.discovery_timeout_ms)
|
||||
.clamp(500, 30_000);
|
||||
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
|
||||
let protocol_version = request.protocol_version.unwrap_or(0).min(2);
|
||||
let protocol_version = request.protocol_version.unwrap_or(0);
|
||||
if protocol_version > 2 {
|
||||
return Err(AppError::BadRequest("protocol_version must be 0, 1 or 2".into()));
|
||||
}
|
||||
let passes = request.passes.unwrap_or(3).clamp(1, 10);
|
||||
let discovered = state
|
||||
.providers
|
||||
@@ -23,52 +138,112 @@ async fn discover(
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
let mut saved = Vec::new();
|
||||
let mut new_device_ids = Vec::new();
|
||||
for item in discovered {
|
||||
let existing = state.db.get_device_by_mac(&item.mac)?;
|
||||
let is_new = existing.is_none();
|
||||
let mut merged = merge_discovered(existing, item);
|
||||
let _device_guard = state.lock_device_operation(&merged.id).await;
|
||||
// A poll/command may have updated the same known device between discovery and
|
||||
// acquiring its operation lock. Re-merge against the freshest persisted state.
|
||||
if !is_new {
|
||||
if let Some(current) = state.db.get_device(&merged.id)? {
|
||||
merged = merge_discovered(Some(current), merged);
|
||||
}
|
||||
}
|
||||
// Bind right after discovery. GREE modules can have a short bind window;
|
||||
// bind() also refreshes it with a direct scan before the handshake.
|
||||
if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.providers.local().client().bind(&merged).await {
|
||||
Ok(bound) => {
|
||||
merged.key = Some(bound.key);
|
||||
merged.protocol_version = bound.protocol_version;
|
||||
merged.communication_failures = 0;
|
||||
merged.last_error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
merged.last_error = Some(format!("discovered, bind pending: {err}"));
|
||||
state.log(
|
||||
"warn",
|
||||
"device.bind_after_discovery",
|
||||
&format!("{}: {err}", merged.name),
|
||||
json!({"device_id": merged.id}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.db.save_device(&merged)?;
|
||||
if is_new {
|
||||
new_device_ids.push(merged.id.clone());
|
||||
}
|
||||
saved.push(merged);
|
||||
Ok((protocol_version, passes, discovered))
|
||||
}
|
||||
|
||||
/// Scan for local GREE units without persisting or binding them.
|
||||
async fn scan_discovery(
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<DiscoveryRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let (protocol_version, passes, discovered) = run_local_discovery(&state, request).await?;
|
||||
let mut candidates = Vec::with_capacity(discovered.len());
|
||||
for device in discovered {
|
||||
let mac = normalize_local_discovery_mac(&device.mac);
|
||||
let already_added = state.db.get_device_by_mac(&mac)?.is_some();
|
||||
candidates.push(local_discovery_candidate(&device, already_added));
|
||||
}
|
||||
state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()}));
|
||||
state.broadcast("devices.discovered", json!({"devices": saved}));
|
||||
Ok(Json(
|
||||
json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids}),
|
||||
))
|
||||
|
||||
state.log(
|
||||
"info",
|
||||
"discovery.scan_complete",
|
||||
&format!("Discovery scan found {} device(s)", candidates.len()),
|
||||
json!({
|
||||
"count": candidates.len(),
|
||||
"protocol_version": protocol_version,
|
||||
"passes": passes,
|
||||
"persisted": false,
|
||||
}),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"count": candidates.len(),
|
||||
"devices": candidates,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn add_discovered_devices(
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<AddDiscoveredDevicesRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
if request.devices.is_empty() {
|
||||
return Err(AppError::BadRequest("select at least one discovered device".into()));
|
||||
}
|
||||
if request.devices.len() > 64 {
|
||||
return Err(AppError::BadRequest("too many discovered devices selected".into()));
|
||||
}
|
||||
|
||||
let selected_count = request.devices.len();
|
||||
let mut added = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
|
||||
for candidate in request.devices {
|
||||
let mut device = device_from_local_discovery(candidate)?;
|
||||
let _device_guard = state.lock_device_operation(&device.id).await;
|
||||
if state.db.get_device_by_mac(&device.mac)?.is_some() {
|
||||
skipped.push(device.mac.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
match state
|
||||
.providers
|
||||
.local()
|
||||
.client()
|
||||
.bind_exact(&device, device.protocol_version)
|
||||
.await
|
||||
{
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
device.communication_failures = 0;
|
||||
device.last_error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
device.last_error = Some(format!("added, bind pending: {err}"));
|
||||
state.log(
|
||||
"warn",
|
||||
"device.bind_after_discovery",
|
||||
&format!("{}: {err}", device.name),
|
||||
json!({
|
||||
"device_id": device.id,
|
||||
"protocol_version": device.protocol_version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state.db.save_device(&device)?;
|
||||
added.push(device);
|
||||
}
|
||||
|
||||
state.log(
|
||||
"info",
|
||||
"discovery.devices_added",
|
||||
&format!("Added {} discovered device(s)", added.len()),
|
||||
json!({
|
||||
"selected": selected_count,
|
||||
"added": added.len(),
|
||||
"skipped": skipped.len(),
|
||||
}),
|
||||
);
|
||||
if !added.is_empty() {
|
||||
state.broadcast("devices.discovered", json!({"devices": added}));
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"count": added.len(),
|
||||
"devices": added,
|
||||
"skipped_macs": skipped,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>, AppError> {
|
||||
|
||||
@@ -71,6 +71,9 @@ mod tests {
|
||||
fn every_documented_operation_has_summary_description_and_responses() {
|
||||
let document = document("");
|
||||
let paths = document["paths"].as_object().expect("OpenAPI paths object");
|
||||
assert!(!paths.contains_key("/api/discovery"));
|
||||
assert!(paths.contains_key("/api/discovery/scan"));
|
||||
assert!(paths.contains_key("/api/discovery/add"));
|
||||
for (path, item) in paths {
|
||||
let methods = item.as_object().expect("OpenAPI path item");
|
||||
for (method, operation) in methods {
|
||||
|
||||
@@ -117,6 +117,29 @@ pub struct DiscoveryRequest {
|
||||
pub passes: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalDiscoveryCandidate {
|
||||
pub name: String,
|
||||
pub mac: String,
|
||||
pub ip: String,
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
/// Detected protocol: 1 = AES-ECB, 2 = AES-GCM.
|
||||
pub protocol_version: u8,
|
||||
#[serde(default)]
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub firmware: String,
|
||||
#[serde(default)]
|
||||
pub already_added: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddDiscoveredDevicesRequest {
|
||||
#[serde(default)]
|
||||
pub devices: Vec<LocalDiscoveryCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManualDeviceRequest {
|
||||
pub name: String,
|
||||
|
||||
@@ -22,7 +22,6 @@ use tokio::{
|
||||
sync::broadcast,
|
||||
time::{timeout, Instant},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BindResult {
|
||||
@@ -51,5 +50,4 @@ include!("gree/polling.rs");
|
||||
include!("gree/commands.rs");
|
||||
include!("gree/transport.rs");
|
||||
include!("gree/network.rs");
|
||||
include!("gree/merge.rs");
|
||||
include!("gree/tests.rs");
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
impl GreeClient {
|
||||
pub async fn bind(&self, device: &Device) -> Result<BindResult> {
|
||||
let versions: &[u8] = match device.protocol_version {
|
||||
2 => &[2, 1],
|
||||
_ => &[1, 2],
|
||||
};
|
||||
let versions = Self::auto_bind_versions(device.protocol_version);
|
||||
self.bind_with_versions(device, versions).await
|
||||
}
|
||||
|
||||
/// Bind using exactly the protocol detected during discovery.
|
||||
pub async fn bind_exact(&self, device: &Device, protocol_version: u8) -> Result<BindResult> {
|
||||
let versions = Self::exact_bind_versions(protocol_version)?;
|
||||
self.bind_with_versions(device, versions).await
|
||||
}
|
||||
|
||||
async fn bind_with_versions(&self, device: &Device, versions: &[u8]) -> Result<BindResult> {
|
||||
let mut errors = Vec::new();
|
||||
for &version in versions {
|
||||
match self.bind_attempt(device, version).await {
|
||||
@@ -22,6 +29,18 @@ impl GreeClient {
|
||||
bail!("unable to bind device ({})", errors.join("; "))
|
||||
}
|
||||
|
||||
fn auto_bind_versions(device_protocol: u8) -> &'static [u8] {
|
||||
if device_protocol == 2 { &[2, 1] } else { &[1, 2] }
|
||||
}
|
||||
|
||||
fn exact_bind_versions(protocol_version: u8) -> Result<&'static [u8]> {
|
||||
match protocol_version {
|
||||
1 => Ok(&[1]),
|
||||
2 => Ok(&[2]),
|
||||
other => bail!("unsupported GREE protocol version: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// GREE Wi-Fi modules use the 12-hex device id as a protocol identifier.
|
||||
/// Older V1 modules (notably 502cc6...) can silently ignore bind/status
|
||||
/// packets when tcid/mac casing differs from the lowercase value returned
|
||||
|
||||
@@ -52,11 +52,8 @@ impl GreeClient {
|
||||
let Ok(value) = serde_json::from_slice::<Value>(&buffer[..size]) else {
|
||||
continue;
|
||||
};
|
||||
match self.parse_discovery(value, source) {
|
||||
match self.parse_discovery(value, source, protocol_filter) {
|
||||
Ok(Some(mut device)) => {
|
||||
if protocol_filter != 0 && device.protocol_version != protocol_filter {
|
||||
continue;
|
||||
}
|
||||
let key = device.mac.to_ascii_lowercase();
|
||||
if seen.insert(key) {
|
||||
device.last_seen = Some(Utc::now());
|
||||
@@ -77,11 +74,24 @@ impl GreeClient {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Result<Option<Device>> {
|
||||
fn parse_discovery(
|
||||
&self,
|
||||
mut value: Value,
|
||||
source: SocketAddr,
|
||||
protocol_filter: u8,
|
||||
) -> Result<Option<Device>> {
|
||||
let mut detected_protocol = 1_u8;
|
||||
if value.get("t").and_then(Value::as_str) == Some("pack") {
|
||||
if let Some(pack_value) = value.get("pack") {
|
||||
if let Some(pack) = pack_value.as_str() {
|
||||
let packet_protocol = if value.get("tag").and_then(Value::as_str).is_some() {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if protocol_filter != 0 && packet_protocol != protocol_filter {
|
||||
return Ok(None);
|
||||
}
|
||||
let clear = if let Some(tag) = value.get("tag").and_then(Value::as_str) {
|
||||
detected_protocol = 2;
|
||||
decrypt_v2(GENERIC_GREE_V2_KEY, pack, tag)?
|
||||
@@ -91,10 +101,16 @@ impl GreeClient {
|
||||
value = serde_json::from_slice::<Value>(&clear)
|
||||
.context("invalid decrypted discovery JSON")?;
|
||||
} else if pack_value.is_object() {
|
||||
if protocol_filter == 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
value = pack_value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if protocol_filter != 0 && detected_protocol != protocol_filter {
|
||||
return Ok(None);
|
||||
}
|
||||
let kind = value
|
||||
.get("t")
|
||||
.and_then(Value::as_str)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device {
|
||||
if let Some(mut old) = existing {
|
||||
old.ip = discovered.ip;
|
||||
old.port = discovered.port;
|
||||
if old.name.trim().is_empty()
|
||||
|| old.name == "Klimatyzator GREE"
|
||||
|| old.name == "GREE air conditioner"
|
||||
{
|
||||
old.name = discovered.name;
|
||||
}
|
||||
if !discovered.model.is_empty() {
|
||||
old.model = discovered.model;
|
||||
}
|
||||
if !discovered.firmware.is_empty() {
|
||||
old.firmware = discovered.firmware;
|
||||
}
|
||||
if old.protocol_version != discovered.protocol_version {
|
||||
old.protocol_version = discovered.protocol_version;
|
||||
old.key = None;
|
||||
}
|
||||
old.online = true;
|
||||
old.communication_failures = 0;
|
||||
old.last_seen = Some(Utc::now());
|
||||
old.last_error = None;
|
||||
old.updated_at = Utc::now();
|
||||
old
|
||||
} else {
|
||||
let mut new = discovered;
|
||||
if new.id.is_empty() {
|
||||
new.id = Uuid::new_v4().to_string();
|
||||
}
|
||||
new
|
||||
}
|
||||
}
|
||||
@@ -118,4 +118,40 @@ mod tests {
|
||||
assert!(device.air);
|
||||
assert_eq!(device.supports_air, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_bind_uses_detected_protocol_exactly() {
|
||||
assert_eq!(GreeClient::exact_bind_versions(1).unwrap(), &[1]);
|
||||
assert_eq!(GreeClient::exact_bind_versions(2).unwrap(), &[2]);
|
||||
assert!(GreeClient::exact_bind_versions(0).is_err());
|
||||
assert!(GreeClient::exact_bind_versions(3).is_err());
|
||||
assert_eq!(GreeClient::auto_bind_versions(1), &[1, 2]);
|
||||
assert_eq!(GreeClient::auto_bind_versions(2), &[2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_protocol_filter_rejects_other_envelope_before_decode() {
|
||||
let client = GreeClient::new(
|
||||
"test-controller".into(),
|
||||
None,
|
||||
None,
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
);
|
||||
let source: SocketAddr = "192.0.2.20:7000".parse().unwrap();
|
||||
let v1 = json!({
|
||||
"t": "pack",
|
||||
"pack": {
|
||||
"t": "dev",
|
||||
"mac": "AABBCCDDEEFF",
|
||||
"name": "Test"
|
||||
}
|
||||
});
|
||||
|
||||
assert!(client
|
||||
.parse_discovery(v1.clone(), source, 1)
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert!(client.parse_discovery(v1, source, 2).unwrap().is_none());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@ pub mod gree;
|
||||
pub mod gree_cloud;
|
||||
pub mod gree_cloud_mqtt;
|
||||
|
||||
pub use gree::{merge_discovered, GreeClient};
|
||||
pub use gree::GreeClient;
|
||||
|
||||
Reference in New Issue
Block a user