Skip to content

Commit a784dab

Browse files
committed
Add support for Smartmi Standing Fan 3 (zhimi.fan.za5)
1 parent 81b51ab commit a784dab

File tree

4 files changed

+496
-5
lines changed

4 files changed

+496
-5
lines changed

miio/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from miio.exceptions import DeviceError, DeviceException
3737
from miio.fan import Fan, FanP5, FanSA1, FanV2, FanZA1, FanZA4
3838
from miio.fan_leshow import FanLeshow
39-
from miio.fan_miot import Fan1C, FanMiot, FanP9, FanP10, FanP11
39+
from miio.fan_miot import Fan1C, FanMiot, FanP9, FanP10, FanP11, FanZA5
4040
from miio.gateway import Gateway
4141
from miio.heater import Heater
4242
from miio.heater_miot import HeaterMiot

miio/discovery.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,13 @@
8787
MODEL_FAN_ZA3,
8888
MODEL_FAN_ZA4,
8989
)
90-
from .fan_miot import MODEL_FAN_1C, MODEL_FAN_P9, MODEL_FAN_P10, MODEL_FAN_P11
90+
from .fan_miot import (
91+
MODEL_FAN_1C,
92+
MODEL_FAN_P9,
93+
MODEL_FAN_P10,
94+
MODEL_FAN_P11,
95+
MODEL_FAN_ZA5,
96+
)
9197
from .heater import MODEL_HEATER_MA1, MODEL_HEATER_ZA1
9298
from .powerstrip import MODEL_POWER_STRIP_V1, MODEL_POWER_STRIP_V2
9399
from .toiletlid import MODEL_TOILETLID_V1
@@ -187,6 +193,7 @@
187193
"dmaker-fan-p9": partial(FanMiot, model=MODEL_FAN_P9),
188194
"dmaker-fan-p10": partial(FanMiot, model=MODEL_FAN_P10),
189195
"dmaker-fan-p11": partial(FanMiot, model=MODEL_FAN_P11),
196+
"zhimi-fan-za5": partial(FanMiot, model=MODEL_FAN_ZA5),
190197
"tinymu-toiletlid-v1": partial(Toiletlid, model=MODEL_TOILETLID_V1),
191198
"zhimi-airfresh-va2": partial(AirFresh, model=MODEL_AIRFRESH_VA2),
192199
"zhimi-airfresh-va4": partial(AirFresh, model=MODEL_AIRFRESH_VA4),

miio/fan_miot.py

Lines changed: 333 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
MODEL_FAN_P10 = "dmaker.fan.p10"
1212
MODEL_FAN_P11 = "dmaker.fan.p11"
1313
MODEL_FAN_1C = "dmaker.fan.1c"
14+
MODEL_FAN_ZA5 = "zhimi.fan.za5"
1415

1516
MIOT_MAPPING = {
1617
MODEL_FAN_1C: {
@@ -67,12 +68,34 @@
6768
"power_off_time": {"siid": 3, "piid": 1},
6869
"set_move": {"siid": 6, "piid": 1},
6970
},
71+
MODEL_FAN_ZA5: {
72+
# https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:fan:0000A005:zhimi-za5:1
73+
"power": {"siid": 2, "piid": 1},
74+
"fan_level": {"siid": 2, "piid": 2},
75+
"swing_mode": {"siid": 2, "piid": 3},
76+
"swing_mode_angle": {"siid": 2, "piid": 5},
77+
"mode": {"siid": 2, "piid": 7},
78+
"power_off_time": {"siid": 2, "piid": 10},
79+
"anion": {"siid": 2, "piid": 11},
80+
"child_lock": {"siid": 3, "piid": 1},
81+
"light": {"siid": 4, "piid": 3},
82+
"buzzer": {"siid": 5, "piid": 1},
83+
"buttons_pressed": {"siid": 6, "piid": 1},
84+
"battery_supported": {"siid": 6, "piid": 2},
85+
"set_move": {"siid": 6, "piid": 3},
86+
"speed_rpm": {"siid": 6, "piid": 4},
87+
"powersupply_attached": {"siid": 6, "piid": 5},
88+
"fan_speed": {"siid": 6, "piid": 8},
89+
"humidity": {"siid": 7, "piid": 1},
90+
"temperature": {"siid": 7, "piid": 7},
91+
},
7092
}
7193

7294
SUPPORTED_ANGLES = {
7395
MODEL_FAN_P9: [30, 60, 90, 120, 150],
7496
MODEL_FAN_P10: [30, 60, 90, 120, 140],
7597
MODEL_FAN_P11: [30, 60, 90, 120, 140],
98+
MODEL_FAN_ZA5: [30, 60, 90, 120],
7699
}
77100

78101

@@ -164,8 +187,7 @@ class FanStatus1C(DeviceStatus):
164187

165188
def __init__(self, data: Dict[str, Any]) -> None:
166189
self.data = data
167-
"""
168-
Response of a Fan1C (dmaker.fan.1c):
190+
"""Response of a Fan1C (dmaker.fan.1c):
169191
170192
{
171193
'id': 1,
@@ -525,3 +547,312 @@ def delay_off(self, minutes: int):
525547
raise FanException("Invalid value for a delayed turn off: %s" % minutes)
526548

527549
return self.set_property("power_off_time", minutes)
550+
551+
552+
class OperationModeFanZA5(enum.Enum):
553+
Nature = 0
554+
Normal = 1
555+
556+
557+
class FanStatusZA5(DeviceStatus):
558+
"""Container for status reports for FanZA5."""
559+
560+
def __init__(self, data: Dict[str, Any]) -> None:
561+
"""Response of FanZA5 (zhimi.fan.za5):
562+
563+
{'code': -4005, 'did': 'set_move', 'piid': 3, 'siid': 6},
564+
{'code': 0, 'did': 'anion', 'piid': 11, 'siid': 2, 'value': True},
565+
{'code': 0, 'did': 'battery_supported', 'piid': 2, 'siid': 6, 'value': False},
566+
{'code': 0, 'did': 'buttons_pressed', 'piid': 1, 'siid': 6, 'value': 0},
567+
{'code': 0, 'did': 'buzzer', 'piid': 1, 'siid': 5, 'value': False},
568+
{'code': 0, 'did': 'child_lock', 'piid': 1, 'siid': 3, 'value': False},
569+
{'code': 0, 'did': 'fan_level', 'piid': 2, 'siid': 2, 'value': 4},
570+
{'code': 0, 'did': 'fan_speed', 'piid': 8, 'siid': 6, 'value': 100},
571+
{'code': 0, 'did': 'humidity', 'piid': 1, 'siid': 7, 'value': 55},
572+
{'code': 0, 'did': 'light', 'piid': 3, 'siid': 4, 'value': 100},
573+
{'code': 0, 'did': 'mode', 'piid': 7, 'siid': 2, 'value': 0},
574+
{'code': 0, 'did': 'power', 'piid': 1, 'siid': 2, 'value': False},
575+
{'code': 0, 'did': 'power_off_time', 'piid': 10, 'siid': 2, 'value': 0},
576+
{'code': 0, 'did': 'powersupply_attached', 'piid': 5, 'siid': 6, 'value': True},
577+
{'code': 0, 'did': 'speed_rpm', 'piid': 4, 'siid': 6, 'value': 0},
578+
{'code': 0, 'did': 'swing_mode', 'piid': 3, 'siid': 2, 'value': True},
579+
{'code': 0, 'did': 'swing_mode_angle', 'piid': 5, 'siid': 2, 'value': 60},
580+
{'code': 0, 'did': 'temperature', 'piid': 7, 'siid': 7, 'value': 26.4},
581+
"""
582+
self.data = data
583+
584+
@property
585+
def ionizer(self) -> str:
586+
"""Ionizer state."""
587+
return "on" if self.data["anion"] else "off"
588+
589+
@property
590+
def is_ionizer_enabled(self) -> bool:
591+
"""True if negative ions generation is enabled."""
592+
return self.data["anion"]
593+
594+
@property
595+
def battery_supported(self) -> bool:
596+
"""True if battery is supported."""
597+
return self.data["battery_supported"]
598+
599+
@property
600+
def buttons_pressed(self) -> str:
601+
"""What buttons on the fan are pressed now."""
602+
code = self.data["buttons_pressed"]
603+
if code == 0:
604+
return "None"
605+
if code == 1:
606+
return "Power"
607+
if code == 2:
608+
return "Swing"
609+
return "Unknown"
610+
611+
@property
612+
def buzzer(self) -> bool:
613+
"""True if buzzer is turned on."""
614+
return self.data["buzzer"]
615+
616+
@property
617+
def child_lock(self) -> bool:
618+
"""True if child lock if on."""
619+
return self.data["child_lock"]
620+
621+
@property
622+
def fan_level(self) -> int:
623+
"""Fan level (1-4)."""
624+
return self.data["fan_level"]
625+
626+
@property
627+
def fan_speed(self) -> int:
628+
"""Fan speed (1-100)."""
629+
return self.data["fan_speed"]
630+
631+
@property
632+
def humidity(self) -> int:
633+
"""Air humidity in percent."""
634+
return self.data["humidity"]
635+
636+
@property
637+
def light(self) -> int:
638+
"""LED brightness (1-100)."""
639+
return self.data["light"]
640+
641+
@property
642+
def mode(self) -> OperationMode:
643+
"""Operation mode (normal or nature)."""
644+
return OperationMode[OperationModeFanZA5(self.data["mode"]).name]
645+
646+
@property
647+
def power(self) -> str:
648+
"""Power state."""
649+
return "on" if self.data["power"] else "off"
650+
651+
@property
652+
def is_on(self) -> bool:
653+
"""True if device is currently on."""
654+
return self.data["power"]
655+
656+
@property
657+
def delay_off_countdown(self) -> int:
658+
"""Countdown until turning off in minutes."""
659+
return self.data["power_off_time"]
660+
661+
@property
662+
def powersupply_attached(self) -> bool:
663+
"""True is power supply is attached."""
664+
return self.data["powersupply_attached"]
665+
666+
@property
667+
def speed_rpm(self) -> int:
668+
"""Fan rotations per minute."""
669+
return self.data["speed_rpm"]
670+
671+
@property
672+
def oscillate(self) -> bool:
673+
"""True if oscillation is enabled."""
674+
return self.data["swing_mode"]
675+
676+
@property
677+
def angle(self) -> int:
678+
"""Oscillation angle."""
679+
return self.data["swing_mode_angle"]
680+
681+
@property
682+
def temperature(self) -> Any:
683+
"""Air temperature (degree celsius)."""
684+
return self.data["temperature"]
685+
686+
687+
class FanZA5(MiotDevice):
688+
mapping = MIOT_MAPPING[MODEL_FAN_ZA5]
689+
690+
def __init__(
691+
self,
692+
ip: str = None,
693+
token: str = None,
694+
start_id: int = 0,
695+
debug: int = 0,
696+
lazy_discover: bool = True,
697+
model: str = MODEL_FAN_ZA5,
698+
) -> None:
699+
super().__init__(ip, token, start_id, debug, lazy_discover)
700+
self.model = model
701+
702+
@command(
703+
default_output=format_output(
704+
"",
705+
"Angle: {result.angle}\n"
706+
"Battery Supported: {result.battery_supported}\n"
707+
"Buttons Pressed: {result.buttons_pressed}\n"
708+
"Buzzer: {result.buzzer}\n"
709+
"Child Lock: {result.child_lock}\n"
710+
"Delay Off Countdown: {result.delay_off_countdown}\n"
711+
"Fan Level: {result.fan_level}\n"
712+
"Fan Speed: {result.fan_speed}\n"
713+
"Humidity: {result.humidity}\n"
714+
"Ionizer: {result.ionizer}\n"
715+
"Light: {result.light}\n"
716+
"Mode: {result.mode.name}\n"
717+
"Oscillate: {result.oscillate}\n"
718+
"Power: {result.power}\n"
719+
"Powersupply Attached: {result.powersupply_attached}\n"
720+
"Speed RPM: {result.speed_rpm}\n"
721+
"Temperature: {result.temperature}\n",
722+
)
723+
)
724+
def status(self):
725+
"""Retrieve properties."""
726+
return FanStatusZA5(
727+
{
728+
prop["did"]: prop["value"] if prop["code"] == 0 else None
729+
for prop in self.get_properties_for_mapping()
730+
}
731+
)
732+
733+
@command(default_output=format_output("Powering on"))
734+
def on(self):
735+
"""Power on."""
736+
return self.set_property("power", True)
737+
738+
@command(default_output=format_output("Powering off"))
739+
def off(self):
740+
"""Power off."""
741+
return self.set_property("power", False)
742+
743+
@command(default_output=format_output("Turning ionizer on"))
744+
def ionizer_on(self):
745+
"""Turn ionizer on."""
746+
return self.set_property("anion", True)
747+
748+
@command(default_output=format_output("Turning ionizer off"))
749+
def ionizer_off(self):
750+
"""Turn ionizer off."""
751+
return self.set_property("anion", False)
752+
753+
@command(
754+
click.argument("speed", type=int),
755+
default_output=format_output("Setting speed to {speed}%"),
756+
)
757+
def set_speed(self, speed: int):
758+
"""Set fan speed."""
759+
if speed < 1 or speed > 100:
760+
raise FanException("Invalid speed: %s" % speed)
761+
762+
return self.set_property("fan_speed", speed)
763+
764+
@command(
765+
click.argument("angle", type=int),
766+
default_output=format_output("Setting angle to {angle}"),
767+
)
768+
def set_angle(self, angle: int):
769+
"""Set the oscillation angle."""
770+
if angle not in SUPPORTED_ANGLES[self.model]:
771+
raise FanException(
772+
"Unsupported angle. Supported values: "
773+
+ ", ".join("{0}".format(i) for i in SUPPORTED_ANGLES[self.model])
774+
)
775+
776+
return self.set_property("swing_mode_angle", angle)
777+
778+
@command(
779+
click.argument("oscillate", type=bool),
780+
default_output=format_output(
781+
lambda oscillate: "Turning on oscillate"
782+
if oscillate
783+
else "Turning off oscillate"
784+
),
785+
)
786+
def set_oscillate(self, oscillate: bool):
787+
"""Set oscillate on/off."""
788+
if oscillate:
789+
return self.set_property("swing_mode", True)
790+
else:
791+
return self.set_property("swing_mode", False)
792+
793+
@command(
794+
click.argument("buzzer", type=bool),
795+
default_output=format_output(
796+
lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer"
797+
),
798+
)
799+
def set_buzzer(self, buzzer: bool):
800+
"""Set buzzer on/off."""
801+
if buzzer:
802+
return self.set_property("buzzer", True)
803+
else:
804+
return self.set_property("buzzer", False)
805+
806+
@command(
807+
click.argument("lock", type=bool),
808+
default_output=format_output(
809+
lambda lock: "Turning on child lock" if lock else "Turning off child lock"
810+
),
811+
)
812+
def set_child_lock(self, lock: bool):
813+
"""Set child lock on/off."""
814+
return self.set_property("child_lock", lock)
815+
816+
@command(
817+
click.argument("light", type=int),
818+
default_output=format_output("Setting light to {light}%"),
819+
)
820+
def set_light(self, light: int):
821+
"""Set indicator brightness."""
822+
if light < 0 or light > 100:
823+
raise FanException("Invalid light: %s" % light)
824+
825+
return self.set_property("light", light)
826+
827+
@command(
828+
click.argument("mode", type=EnumType(OperationMode)),
829+
default_output=format_output("Setting mode to '{mode.value}'"),
830+
)
831+
def set_mode(self, mode: OperationMode):
832+
"""Set mode."""
833+
return self.set_property("mode", OperationModeFanZA5[mode.name].value)
834+
835+
@command(
836+
click.argument("seconds", type=int),
837+
default_output=format_output("Setting delayed turn off to {seconds} seconds"),
838+
)
839+
def delay_off(self, seconds: int):
840+
"""Set delay off seconds."""
841+
842+
if seconds < 0 or seconds > 10 * 60 * 60:
843+
raise FanException("Invalid value for a delayed turn off: %s" % seconds)
844+
845+
return self.set_property("power_off_time", seconds)
846+
847+
@command(
848+
click.argument("direction", type=EnumType(MoveDirection)),
849+
default_output=format_output("Rotating the fan to the {direction}"),
850+
)
851+
def set_rotate(self, direction: MoveDirection):
852+
"""Rotate fan 7.5 degrees horizontally to given direction."""
853+
status = self.status()
854+
if status.oscillate:
855+
raise FanException(
856+
"Rotation requires oscillation to be turned off to function."
857+
)
858+
return self.set_property("set_move", direction.name.lower())

0 commit comments

Comments
 (0)