mqttcommander 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ import json
2
+ from datetime import datetime, date, timedelta
3
+ from typing import Any
4
+ from uuid import UUID
5
+
6
+
7
+ def print_pretty_dict_json(data: Any, indent: int = 4) -> None:
8
+ print(json.dumps(data, indent=indent, sort_keys=True, cls=ComplexEncoder, default=str))
9
+
10
+
11
+ def get_pretty_dict_json(data: Any, indent: int = 4) -> str:
12
+ return json.dumps(data, indent=indent, sort_keys=True, cls=ComplexEncoder, default=str)
13
+
14
+
15
+ def get_pretty_dict_json_no_sort(data: Any, indent: int = 4) -> str:
16
+ return json.dumps(data, indent=indent, sort_keys=False, cls=ComplexEncoder, default=str)
17
+
18
+
19
+ class ComplexEncoder(json.JSONEncoder):
20
+ def default(self, obj: Any) -> Any:
21
+ if hasattr(obj, "repr_json"):
22
+ return obj.repr_json()
23
+ elif hasattr(obj, "as_string"):
24
+ return obj.as_string()
25
+ elif isinstance(obj, UUID):
26
+ return str(obj)
27
+ elif isinstance(obj, datetime):
28
+ return obj.isoformat() # strftime("%Y-%m-%d %H:%M:%S %Z")
29
+ elif isinstance(obj, date):
30
+ return obj.strftime("%Y-%m-%d")
31
+ elif isinstance(obj, timedelta):
32
+ return str(obj)
33
+ elif isinstance(obj, dict) or isinstance(obj, list):
34
+ robj: str = get_pretty_dict_json_no_sort(obj)
35
+ return robj
36
+ else:
37
+ return json.JSONEncoder.default(self, obj)
@@ -0,0 +1,88 @@
1
+ __version__ = "0.0.1"
2
+
3
+ import os
4
+ import sys
5
+ from typing import Callable, Dict, Any
6
+
7
+ from loguru import logger as glogger
8
+
9
+ glogger.disable(__name__)
10
+
11
+
12
+ def _loguru_skiplog_filter(record: dict) -> bool:
13
+ """Filter function to hide records with ``extra['skiplog']`` set.
14
+
15
+ Intended for use with ``loguru``'s ``filter=`` parameter so that callers can
16
+ temporarily suppress verbose logs for specific operations by binding
17
+ ``skiplog=True`` on the logger.
18
+
19
+ Args:
20
+ record (dict): Loguru record dictionary.
21
+
22
+ Returns:
23
+ bool: ``True`` to keep the record, ``False`` to skip it.
24
+ """
25
+ # {
26
+ # "elapsed": timedelta, # Zeit seit Programmstart
27
+ # "exception": tuple, # Exception-Info (type, value, traceback) oder None
28
+ # "extra": dict, # Benutzerdefinierte Extra-Felder
29
+ # "file": RecordFile, # Datei-Info (name, path)
30
+ # "function": str, # Name der Funktion
31
+ # "level": RecordLevel, # Level-Info (name, no, icon)
32
+ # "line": int, # Zeilennummer
33
+ # "message": str, # Formatierte Nachricht
34
+ # "module": str, # Modulname
35
+ # "name": str, # Logger-Name
36
+ # "process": RecordProcess, # Process-Info (id, name)
37
+ # "thread": RecordThread, # Thread-Info (id, name)
38
+ # "time": datetime # Zeitstempel des Log-Eintrags
39
+ # }
40
+ return not record.get("extra", {}).get("skiplog", False)
41
+
42
+
43
+ def configure_loguru_default_with_skiplog_filter(
44
+ loguru_filter: Callable[[Dict[str, Any]], bool] = _loguru_skiplog_filter,
45
+ ) -> None:
46
+ """Configure a default ``loguru`` sink with a convenient format and filter.
47
+
48
+ This sets a colored formatter, applies the given filter (by default
49
+ :func:`_loguru_skiplog_filter`), and ensures a reasonable default log level
50
+ via the ``LOGURU_LEVEL`` environment variable.
51
+
52
+ Args:
53
+ loguru_filter: A callable taking a record dict and returning ``True``
54
+ if the record should be emitted. Defaults to
55
+ :func:`_loguru_skiplog_filter`.
56
+ """
57
+ glogger.info("configure_loguru_default_with_skiplog_filter")
58
+
59
+ os.environ["LOGURU_LEVEL"] = os.getenv("LOGURU_LEVEL", "DEBUG") # standard is DEBUG
60
+ glogger.remove() # remove default-handler
61
+ logger_fmt: str = (
62
+ "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{module}</cyan>::<cyan>{extra[classname]}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
63
+ )
64
+ # logger_fmt: str = "<g>{time:HH:mm:ssZZ}</> | <lvl>{level}</> | <c>{module}::{extra[classname]}:{function}:{line}</> - {message}"
65
+
66
+ glogger.add(sys.stderr, level=os.getenv("LOGURU_LEVEL"), format=logger_fmt, filter=loguru_filter) # type: ignore # TRACE | DEBUG | INFO | WARN | ERROR | FATAL
67
+ glogger.configure(extra={"classname": "None", "skiplog": False})
68
+
69
+
70
+ from .tasmotacommander import (
71
+ MqttCommander,
72
+ read_tasmotas_from_latest_file,
73
+ write_tasmota_devices_file,
74
+ TASMOTA_DEFAULT_TOPICS,
75
+ TASMOTA_DISCOVERY_TOPIC_BEGIN,
76
+ TASMOTA_LWT_TOPIC_BEGIN,
77
+ TASMOTA_LWT_TOPIC_END,
78
+ )
79
+
80
+ from .models import (
81
+ TasmotaDevice,
82
+ TasmotaDeviceConfig,
83
+ TasmotaDeviceSensors,
84
+ TasmotaTimerConfig,
85
+ TasmotaTimezoneConfig,
86
+ TasmotaRule,
87
+ TasmotaTimeZoneDSTSTD,
88
+ )
mqttcommander/cli.py ADDED
@@ -0,0 +1,112 @@
1
+ from typing import Optional, List, cast, Union
2
+
3
+ from loguru import logger
4
+
5
+ import mqttcommander
6
+ from mqttcommander import (
7
+ MqttCommander,
8
+ TASMOTA_DEFAULT_TOPICS,
9
+ read_tasmotas_from_latest_file,
10
+ TasmotaDevice,
11
+ )
12
+
13
+
14
+ def _run(
15
+ host: str,
16
+ port: int,
17
+ username: str,
18
+ password: str,
19
+ action: str,
20
+ command: Optional[str] = None,
21
+ values: Optional[List[str]] = None,
22
+ ) -> None:
23
+ mqttcommander.configure_loguru_default_with_skiplog_filter()
24
+ logger.enable("mqttcommander")
25
+
26
+ comm: MqttCommander = MqttCommander(
27
+ topics=TASMOTA_DEFAULT_TOPICS,
28
+ host=host,
29
+ port=port,
30
+ username=username,
31
+ password=password,
32
+ )
33
+
34
+ match action:
35
+ case "readfromfile":
36
+ tasmotas = read_tasmotas_from_latest_file(noisy=True)
37
+ count = len(tasmotas) if tasmotas else 0
38
+ print(f"Loaded {count} tasmota devices from latest file")
39
+ case "list-retained":
40
+ msgs = comm.get_all_retained(noisy=False)
41
+ cnt = 0 if msgs is None else len(msgs)
42
+ print(f"Retained messages matching topics {comm.topics}: {cnt}")
43
+ if msgs:
44
+ for m in msgs[:10]:
45
+ print(f"- {m.topic}")
46
+ case "list-tasmotas":
47
+ all_devs = comm.get_all_tasmota_devices_from_retained(noisy=False)
48
+ print(f"Found {len(all_devs)} tasmota devices from retained data")
49
+ for d in all_devs:
50
+ tc = d.tasmota_config
51
+ name = None
52
+ if tc is not None:
53
+ name = tc.device_name or tc.friendly_name or tc.topic
54
+ print(f"- {name}")
55
+ case "list-online":
56
+ all_devs = comm.get_all_tasmota_devices_from_retained(noisy=False)
57
+ online = comm.filter_online_tasmotas_from_retained(all_devs)
58
+ print(f"Online devices: {len(online)} / {len(all_devs)}")
59
+ for d in online:
60
+ tc = d.tasmota_config
61
+ name = None
62
+ if tc is not None:
63
+ name = tc.device_name or tc.friendly_name or tc.topic
64
+ print(f"- {name} online={d.lwt_current_value}")
65
+ case "send-cmd":
66
+ if not command:
67
+ raise SystemExit("--command is required for action send-cmd")
68
+ all_devs = comm.get_all_tasmota_devices_from_retained(noisy=False)
69
+ online = comm.filter_online_tasmotas_from_retained(all_devs)
70
+ vals_typed = cast(List[List[Union[str, float, dict, int]] | None] | None, [values] if values is not None else None)
71
+ comm.send_cmds_to_online_tasmotas(online, to_be_used_commands=[command], values_to_send=vals_typed)
72
+ case _:
73
+ raise SystemExit(f"Unknown action: {action}")
74
+
75
+
76
+ def main(argv: Optional[List[str]] = None) -> None:
77
+ import argparse
78
+
79
+ parser = argparse.ArgumentParser(description="mqttcommander CLI")
80
+
81
+ # Common connection options
82
+ parser.add_argument("--host", type=str, required=True, help="MQTT broker host")
83
+ parser.add_argument("--port", type=int, required=True, help="MQTT broker port")
84
+ parser.add_argument("--username", type=str, required=True, help="MQTT username")
85
+ parser.add_argument("--password", type=str, required=True, help="MQTT password")
86
+
87
+ sub = parser.add_subparsers(dest="action", required=True)
88
+
89
+ sub.add_parser("readfromfile", help="Read latest saved tasmota devices file and show count")
90
+ sub.add_parser("list-retained", help="List count of retained messages for default topics")
91
+ sub.add_parser("list-tasmotas", help="Build device list from retained discovery and print devices")
92
+ sub.add_parser("list-online", help="Filter online devices and print them")
93
+
94
+ p_send = sub.add_parser("send-cmd", help="Send a command to all online devices")
95
+ p_send.add_argument("--command", required=True, help="Command, e.g. Power")
96
+ p_send.add_argument("--values", nargs="*", help="Values to send, e.g. Toggle or 1 0 ...")
97
+
98
+ args = parser.parse_args(argv) # argv wird hier verwendet
99
+
100
+ _run(
101
+ host=args.host,
102
+ port=args.port,
103
+ username=args.username,
104
+ password=args.password,
105
+ action=args.action,
106
+ command=getattr(args, "command", None),
107
+ values=getattr(args, "values", None),
108
+ )
109
+
110
+
111
+ if __name__ == "__main__":
112
+ main()
@@ -0,0 +1,201 @@
1
+ from datetime import datetime
2
+ from io import StringIO
3
+ from typing import Optional, Annotated, List, Literal, Any, Self
4
+
5
+ from pydantic import BaseModel, Field, AliasPath, field_validator, AliasChoices
6
+ from pydantic.networks import IPv4Address
7
+ from pydantic_extra_types.mac_address import MacAddress
8
+
9
+
10
+ class TasmotaTimerConfig(BaseModel):
11
+ # Enable 0 = disarm or disable timer
12
+ # 1 = arm or enable timer
13
+ enable: Optional[Annotated[int, Field(ge=0, le=1)]] = Field(None, validation_alias=AliasChoices("Enable", "enable"))
14
+
15
+ # Mode 0 = use clock time
16
+ # 1 = Use local sunrise time using Longitude, Latitude and Time offset
17
+ # 2 = use local sunset time using Longitude, Latitude and Time offset
18
+ mode: Optional[Annotated[int, Field(ge=0, le=2)]] = Field(None, validation_alias=AliasChoices("Mode", "mode"))
19
+
20
+ # Time When Mode 0 is active
21
+ # > hh:mm = set time in hours 0 .. 23 and minutes 0 .. 59
22
+ # When Mode 1 or Mode 2 is active
23
+ # > +hh:mm or -hh:mm = set offset in hours 0 .. 11 and minutes 0 .. 59 from the time defined by sunrise/sunset.
24
+ time: Optional[str] = Field(None, validation_alias=AliasChoices("Time", "time"))
25
+
26
+ # Window 0..15 = add or subtract a random number of minutes to Time
27
+ window: Optional[Annotated[int, Field(ge=0, le=15)]] = Field(
28
+ None, validation_alias=AliasChoices("Window", "window")
29
+ )
30
+
31
+ # Days SMTWTFS = set day of weeks mask where 0 or - = OFF and any different character = ON
32
+ days: Optional[Annotated[str, Field(pattern="[1|0|-]{7}")]] = Field(
33
+ None, validation_alias=AliasChoices("Days", "days")
34
+ )
35
+
36
+ # Repeat 0 = allow timer only once
37
+ # 1 = repeat timer execution
38
+ repeat: Optional[Annotated[int, Field(ge=0, le=1)]] = Field(None, validation_alias=AliasChoices("Repeat", "repeat"))
39
+
40
+ # Output 1..16 = select an output to be used if no rule is enabled
41
+ output: Optional[Annotated[int, Field(ge=1, le=16)]] = Field(
42
+ None, validation_alias=AliasChoices("Output", "output")
43
+ )
44
+
45
+ # Action 0 = turn output OFF
46
+ # 1 = turn output ON
47
+ # 2 = TOGGLE output
48
+ # 3 = RULE/BLINK
49
+ # If the Tasmota Rules feature has been activated by compiling the code (activated by default in all pre-compiled Tasmota binaries), a rule with Clock#Timer=<timer> will be triggered if written and turned on by the user.
50
+ # If Rules are not compiled, BLINK output using BlinkCount parameters.
51
+ action: Optional[Annotated[int, Field(ge=1, le=16)]] = Field(
52
+ None, validation_alias=AliasChoices("Action", "action")
53
+ )
54
+
55
+ # {"Timer1":{"Enable":1,"Mode":0,"Time":"22:00","Window":0,"Days":"1111111","Repeat":1,"Output":1,"Action":0}}
56
+
57
+
58
+ class TasmotaTimeZoneDSTSTD(BaseModel):
59
+ hemisphere: Optional[int] = Field(None, validation_alias=AliasChoices("Hemisphere", "hemisphere"))
60
+ week: Optional[int] = Field(None, validation_alias=AliasChoices("Week", "week"))
61
+ month: Optional[int] = Field(None, validation_alias=AliasChoices("Month", "month"))
62
+ day: Optional[int] = Field(None, validation_alias=AliasChoices("Day", "day"))
63
+ hour: Optional[int] = Field(None, validation_alias=AliasChoices("Hour", "hour"))
64
+ offset: Optional[int] = Field(None, validation_alias=AliasChoices("Offset", "offset"))
65
+
66
+ def to_tasmota_command_string(self) -> str:
67
+ return str([self.hemisphere,self.week,self.month,self.day,self.hour,self.offset]).replace(" ", "")
68
+
69
+ @classmethod
70
+ def from_tasmota_command_string(cls, values_comma_separated: str) -> TasmotaTimeZoneDSTSTD:
71
+ data: List[str] = values_comma_separated.split(",")
72
+
73
+ assert len(data) == 6
74
+
75
+ return TasmotaTimeZoneDSTSTD(
76
+ hemisphere=int(data[0]),
77
+ week=int(data[1]),
78
+ month=int(data[2]),
79
+ day=int(data[3]),
80
+ hour=int(data[4]),
81
+ offset=int(data[5])
82
+ )
83
+
84
+ class TasmotaTimezoneConfig(BaseModel):
85
+ latitude: Optional[float] = Field(None, validation_alias=AliasChoices("Latitude", "latitude"))
86
+ longitude: Optional[float] = Field(None, validation_alias=AliasChoices("Longitude", "longitude"))
87
+ timedst: Optional[TasmotaTimeZoneDSTSTD] = Field(None, validation_alias=AliasChoices("TimeDst", "timedst"))
88
+ timestd: Optional[TasmotaTimeZoneDSTSTD] = Field(None, validation_alias=AliasChoices("TimeStd", "timestd"))
89
+ timezone: Optional[int | str] = Field(None, validation_alias=AliasChoices("Timezone", "timezone")) # 99 | +01:00
90
+
91
+ def as_tasmota_command_list(self) -> List[str | float | dict[Any, Any] | int] | None:
92
+ assert self.latitude is not None and \
93
+ self.longitude is not None and \
94
+ self.timedst is not None and \
95
+ self.timestd is not None and \
96
+ self.timezone is not None
97
+
98
+ return [
99
+ self.latitude,
100
+ self.longitude,
101
+ self.timedst.to_tasmota_command_string(), # that is correct!
102
+ self.timestd.to_tasmota_command_string(), # that is correct!
103
+ self.timezone
104
+ ]
105
+
106
+ def to_tasmota_command_string(self) -> str:
107
+ return str(self.as_tasmota_command_list()).replace(" ", "")
108
+
109
+
110
+ class TasmotaDeviceConfig(BaseModel):
111
+ friendly_name: Optional[str] = Field(
112
+ None, validation_alias=AliasChoices("friendly_name", AliasPath("fn", 0))
113
+ ) # friendly name -> first element of list of strings?
114
+ device_name: Optional[str] = Field(None, validation_alias=AliasChoices("dn", "device_name"))
115
+ hostname: Optional[str] = Field(None, validation_alias=AliasChoices("hn", "hostname")) # host name
116
+ manufacturer_description: Optional[str] = Field(
117
+ None, validation_alias=AliasChoices("md", "manufacturer_description")
118
+ ) # manufacturer description ?!
119
+ ip: Optional[IPv4Address] = None
120
+ mac: Optional[MacAddress] = None
121
+ offline_msg: Optional[str] = Field(None, validation_alias=AliasChoices("ofln", "offline_msg"))
122
+ online_msg: Optional[str] = Field(None, validation_alias=AliasChoices("onln", "online_msg"))
123
+ state: Optional[List[str]] = None
124
+ # t: str = Field(alias="topic", validation_alias='t')
125
+ topic: Optional[str] = Field(None, validation_alias=AliasChoices("t", "topic"))
126
+ tp: Optional[List[str]] = Field(None)
127
+ software_version: Optional[str] = Field(None, validation_alias=AliasChoices("sw", "software_version"))
128
+ timezoneconfig: Optional[TasmotaTimezoneConfig] = None
129
+ teleperiod: Optional[int] = Field(None, validation_alias=AliasChoices("TelePeriod", "teleperiod"))
130
+ powerdelta1: Optional[int] = Field(None, validation_alias=AliasChoices("PowerDelta1", "powerdelta1"))
131
+ setoption4: Optional[Literal["ON", "OFF"]] = Field(None, validation_alias=AliasChoices("SetOption4", "setoption4"))
132
+ timer1: Optional[TasmotaTimerConfig] = Field(None, validation_alias=AliasChoices("Timer1", "timer1"))
133
+ timer2: Optional[TasmotaTimerConfig] = Field(None, validation_alias=AliasChoices("Timer2", "timer2"))
134
+ timer3: Optional[TasmotaTimerConfig] = Field(None, validation_alias=AliasChoices("Timer2", "timer3"))
135
+ timer4: Optional[TasmotaTimerConfig] = Field(None, validation_alias=AliasChoices("Timer4", "timer4"))
136
+
137
+ # SSID1
138
+ # SSID2
139
+ # powerdelta
140
+ # ampres
141
+ # voltres
142
+ # setoption4
143
+ # SetOption53 1; SetOption56 0; SetOption57 0;
144
+
145
+ @field_validator("mac", mode="before")
146
+ @classmethod
147
+ def validate_mac(cls, v: Any) -> Optional[str]:
148
+ # logger.debug(f"VALIDATE MAC: {v}")
149
+ if not v:
150
+ return None
151
+
152
+ return cls.mac_no_colon_to_colon(v)
153
+
154
+ @staticmethod
155
+ def mac_no_colon_to_colon(mac: str) -> str:
156
+ if mac.find(":") == 2:
157
+ return mac
158
+
159
+ ret: StringIO = StringIO()
160
+ for ch2i in range(0, len(mac), 2):
161
+ if ret.tell() > 0:
162
+ ret.write(":")
163
+ ret.write(mac[ch2i : ch2i + 2])
164
+
165
+ return ret.getvalue()
166
+
167
+
168
+ class TasmotaRule(BaseModel):
169
+ state: Optional[Literal["ON", "OFF"]] = Field(None, validation_alias=AliasChoices("State", "state"))
170
+ once: Optional[Literal["ON", "OFF"]] = Field(None, validation_alias=AliasChoices("Once", "once"))
171
+ stoponerror: Optional[Literal["ON", "OFF"]] = Field(
172
+ None, validation_alias=AliasChoices("StopOnError", "stoponerror")
173
+ )
174
+ length: Optional[int] = Field(None, validation_alias=AliasChoices("Length", "length"))
175
+ rules: Optional[str] = Field(None, validation_alias=AliasChoices("Rules", "rules"))
176
+
177
+
178
+ class TasmotaDeviceSensors(BaseModel):
179
+ # tasmota/discovery/x/sensors
180
+ # sn: dict
181
+ time: datetime = Field(..., validation_alias=AliasChoices("time", "Time", AliasPath("sn", "Time")))
182
+
183
+ # {"sn": {"Time": "2024-07-04T13:09:25", "ANALOG": {"A0": 169},
184
+ # "SHT3X": {"Temperature": 26.1, "Humidity": 44.5, "DewPoint": 13.1}, "TempUnit": "C"}, "ver": 1}
185
+
186
+
187
+ class TasmotaDevice(BaseModel):
188
+ # tasmota/discovery/x/config
189
+ tasmota_config: Optional[TasmotaDeviceConfig] = None
190
+ tasmota_sensors: Optional[TasmotaDeviceSensors] = None
191
+ tasmota_rule1: Optional[TasmotaRule] = None
192
+ tasmota_rule2: Optional[TasmotaRule] = None
193
+ tasmota_rule3: Optional[TasmotaRule] = None
194
+
195
+ lwt_current_value: Optional[Literal["Online", "Offline"]] = None
196
+
197
+ def is_online(self, lwt_online_default_value: str = "Online") -> bool:
198
+ if self.tasmota_config and self.tasmota_config.online_msg:
199
+ return self.lwt_current_value == self.tasmota_config.online_msg
200
+
201
+ return self.lwt_current_value == lwt_online_default_value
mqttcommander/py.typed ADDED
File without changes