-
Notifications
You must be signed in to change notification settings - Fork 1
/
trinnov_altitude.py
789 lines (673 loc) · 25.6 KB
/
trinnov_altitude.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
"""
Implements the Trinnov Altitude processor automation protocol over TCP/IP
"""
import asyncio
from collections.abc import Callable
import logging
import re
from typing import TypeAlias
from wakeonlan import send_magic_packet
from trinnov_altitude import const, exceptions, messages
Callback: TypeAlias = Callable[[str, messages.Message | None], None]
class TrinnovAltitude:
"""
Trinnov Altitude
A class for interfacing with the Trinnov Altitude processor via the TCP/IP protocol.
"""
DEFAULT_CLIENT_ID = "py-trinnov-altitude"
DEFAULT_PORT = 44100
DEFAULT_TIMEOUT = 2.0
ENCODING = "ascii"
VOLUME_MIN = -120.0
VOLUME_MAX = 20.0
# Use a sentinel value to signal that the DEFAULT_TIMEOUT should be used.
# This allows users to pass None and disable the timeout to wait indefinitely.
USE_DEFAULT_TIMEOUT = -1.0
@classmethod
def validate_mac(cls, mac_address):
"""
Validate, to the best of our abilities, that the Mac address is a
valid Trinnov Altitude Mac address.
."""
normalized_mac_address = mac_address.lower()
# Verify the format
pattern = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$")
if pattern.match(normalized_mac_address) is None:
raise exceptions.MalformedMacAddressError(mac_address)
return True
def __init__(
self,
host: str,
port: int = DEFAULT_PORT,
mac: str | None = None,
client_id: str = DEFAULT_CLIENT_ID,
timeout: float = DEFAULT_TIMEOUT,
logger: logging.Logger = logging.getLogger(__name__),
):
if mac is not None:
self.__class__.validate_mac(mac)
# Settings
self.host = host
self.port = port
self.mac = mac
self.client_id = client_id
self.timeout = timeout
self.logger = logger
# State
self.audiosync: str | None = None
self.bypass: bool | None = None
self.decoder: str | None = None
self.dim: bool | None = None
self.id: str | None = None
self.mute: bool | None = None
self.preset: str | None = None
self.presets: dict[int, str] = {}
self.source: str | None = None
self.source_format: str | None = None
self.sources: dict[int, str] = {}
self.upmixer: str | None = None
self.version: str | None = None
self.volume: float | None = None
# Utility
self._callbacks: set[Callback] = set()
self._initial_sync = asyncio.Event()
self._reader: asyncio.StreamReader | None = None
self._response_handler_task: asyncio.Task | None = None
self._writer: asyncio.StreamWriter | None = None
# --------------------------
# Connection
# --------------------------
async def connect(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""Initiates the TCP connection to the processor"""
if self.connected():
self.logger.warn(
"Trinnov Altitude already connected, use `reconnect` to establish a new connection"
)
return
self.logger.info("Connecting to Trinnov Altitude: %s:%s", self.host, self.port)
if timeout is self.USE_DEFAULT_TIMEOUT:
timeout = self.timeout
try:
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self.host, self.port), timeout
)
except asyncio.TimeoutError as e:
raise exceptions.ConnectionTimeoutError from e
except (OSError, ValueError) as e:
raise exceptions.ConnectionFailedError(e)
else:
# Default these values since the Trinnov Altitude will only
# send them upon connect if they are active.
self.bypass = False
self.dim = False
self.mute = False
# Fire the callback to signal a connection state change
for callback in self._callbacks:
callback("connected", None)
await self._write(f"id {self.client_id}", timeout)
await self._write("send volume", timeout)
await self.preset_get(timeout)
await self.source_get(timeout)
def connected(self) -> bool:
"""Returns the connection state."""
return self._reader is not None and self._writer is not None
def deregister_callback(self, callback: Callback):
"""
Deregister a callback.
"""
self._callbacks.remove(callback)
async def disconnect(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""Closes the TCP connection to the processor"""
if self._writer is None:
self.logger.warning("Not connected to Trinnov Altitude, can't disconnect")
return
if timeout is self.USE_DEFAULT_TIMEOUT:
timeout = self.timeout
self._writer.close()
self._reader = None
self._writer = None
# Reset state
self.audiosync = None
self.bypass = None
self.decoder = None
self.dim = None
self.mute = None
self.preset = None
self.source = None
self.source_format = None
self.upmixer = None
self.volume = None
# Fire the callback to signal a connection state change
for callback in self._callbacks:
callback("disconnected", None)
async def reconnect(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Reconnect to the processor.
This will close any active connections and open a new one. This method
must be used if you watn to explicitly reconnect, as `connect` will
return early if a connection is already established.
"""
if self._writer:
await self.disconnect(timeout)
await self.connect(timeout)
def register_callback(self, callback: Callback):
"""
Register a callback to be fired when messages are received from
the processor.
"""
self._callbacks.add(callback)
def start_listening(
self,
callback: Callback | None = None,
reconnect: bool = True,
backoff: int = 2,
):
if callback:
self.register_callback(callback)
self._response_handler_task = asyncio.create_task(
self._listen(reconnect, backoff)
)
async def stop_listening(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
if self._response_handler_task:
if timeout is self.USE_DEFAULT_TIMEOUT:
timeout = self.timeout
self._response_handler_task.cancel()
await asyncio.wait_for(self._response_handler_task, timeout)
self._response_handler_task = None
async def wait_for_initial_sync(self, timeout: int | float | None = 10):
"""
Wait for the initial sync from the processor.
This is a basic method that returns when id, presets, sources, and version
are set. There is no guarantee regarding when the processor will respond
to a new client with current state messages. Additionally, there is no
message indicating that the initial synchronization is complete. Therefore,
the best approach is to monitor for changes that suggest the initial
synchronization has been completed.
"""
await asyncio.wait_for(self._initial_sync.wait(), timeout)
# --------------------------
# Properties
# --------------------------
def volume_percentage(self) -> float | None:
if self.volume is None:
return None
return (
(self.volume - self.VOLUME_MIN) / (self.VOLUME_MAX - self.VOLUME_MIN)
) * 100
# --------------------------
# Commands
# --------------------------
async def acoustic_correction_off(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Turn the acoustic correction off.
"""
await self.acoustic_correction_set(False)
async def acoustic_correction_on(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Turn the acoustic correction on.
"""
await self.acoustic_correction_set(True)
async def acoustic_correction_set(
self, state: bool, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the acoustic correction to On (True) or Off (False)
"""
await self._write(f"use_acoustic_correct {int(state)}", timeout)
async def acoustic_correction_toggle(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Toggle the acoustic correction state.
"""
await self._write("use_acoustic_correct 2", timeout)
async def bypass_off(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Turn the bypass off
"""
await self.bypass_set(False)
async def bypass_on(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Turn the bypass on
"""
await self.bypass_set(True)
async def bypass_set(
self, state: bool, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the bypass state to On (True) or Off (False)
"""
await self._write(f"bypass {int(state)}", timeout)
async def bypass_toggle(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Toggle the bypass state.
"""
await self._write("bypass 2", timeout)
async def dim_off(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Turn the dim off.
"""
await self.dim_set(False)
async def dim_on(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Turn the dim on.
"""
await self.dim_set(True)
async def dim_set(
self, state: bool, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the dim state to On (True) or Off (False)
"""
await self._write(f"dim {int(state)}", timeout)
async def dim_toggle(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Toggle the dim state.
"""
await self._write("dim 2", timeout)
async def front_display_off(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Turn the front display off.
"""
await self.front_display_set(False)
async def front_display_on(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Turn the front display on.
"""
await self.front_display_set(True)
async def front_display_set(
self, state: bool, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the front display of the processor to On (True) or Off (False).
"""
await self._write(f"fav_light {int(state)}", timeout)
async def front_display_toggle(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Toggle the front display of the processor.
"""
await self._write("dim 2", timeout)
async def level_alignment_off(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Turn the level alignment off.
"""
await self.level_alignment_set(False)
async def level_alignment_on(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Turn the level alignment on.
"""
await self.level_alignment_set(True)
async def level_alignment_set(
self, state: bool, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the level alignment state to On (True) or Off (False)
"""
await self._write(f"use_level_alignment {int(state)}", timeout)
async def level_alignment_toggle(
self, state, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Toggle the level alignment state.
"""
await self._write("use_level_alignment 2", timeout)
async def mute_off(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Turn the mute off.
"""
await self.mute_set(False)
async def mute_on(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Turn the mute on.
"""
await self.mute_set(True)
async def mute_set(
self, state: bool, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the mute state to On (True) or Off (False)
"""
await self._write(f"mute {int(state)}", timeout)
async def mute_toggle(
self, state, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Toggle the mute state.
"""
await self._write("mute 2", timeout)
async def page_adjust(
self, delta: int, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Changes the menu page currently on the GUI. `delta` indicates the number of
pages to change, and may be positive or negative.
"""
await self._write(f"page_adjust {delta}", timeout)
async def page_down(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Changes the menu page currently on the GUI down by one page.
"""
await self.page_adjust(-1)
async def page_up(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Changes the menu page currently on the GUI up by one page.
"""
await self.page_adjust(1)
async def power_off(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""Power off."""
await self._write("power_off_SECURED_FHZMCH48FE", timeout)
def power_on(self):
"""Power on."""
if self.mac is None:
raise exceptions.NoMacAddressError()
send_magic_packet(self.mac)
def power_on_available(self) -> bool:
"""
Can the device be powered on.
A mac address is required to do so.
"""
return self.mac is not None
async def preset_get(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Requests the current present.
"""
await self._write("get_current_preset", timeout)
async def preset_set(
self, id: int, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the preset identified by `id`. Preset `0` is the built-in preset and
presets >= `1` are user defined presets.
"""
await self._write(f"loadp {id}", timeout)
async def quick_optimized_off(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Turn quick optimized off.
"""
await self.quick_optimized_set(False)
async def quick_optimized_on(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Turn quick optimized on.
"""
await self.quick_optimized_set(True)
async def quick_optimized_set(
self, state: bool, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the quick optimized state to On (True) or Off (False)
"""
await self._write(f"quick_optimized {int(state)}", timeout)
async def quick_optimized_toggle(
self, state, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Toggle the quick optimized state.
"""
await self._write("quick_optimized 2", timeout)
async def remapping_mode_set(
self,
mode: const.RemappingMode,
timeout: int | float | None = USE_DEFAULT_TIMEOUT,
):
"""
Set the remapping mode. See `const.RemappingMode` for available options
and descriptions.
"""
await self._write(f"remapping_mode {mode.value}", timeout)
async def source_get(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Requests the current source.
"""
await self._write("get_current_profile", timeout)
async def source_set(
self, id: int, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the source identified by `id`, where `0` is the first source.
"""
await self._write(f"profile {id}", timeout)
async def source_set_by_name(
self, name: str, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the source identified by `name` from `sources`.
"""
for source_id, source_name in self.sources.items():
if source_name == name:
await self.source_set(source_id)
return
async def time_alignment_off(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Turn time alignment off.
"""
await self.time_alignment_set(False)
async def time_alignment_on(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Turn time alignment on.
"""
await self.time_alignment_set(True)
async def time_alignment_set(
self, state: bool, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the time alignment state to On (True) or Off (False)
"""
await self._write(f"use_time_alignment {int(state)}", timeout)
async def time_alignment_toggle(
self, state, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Toggle the time alignment state.
"""
await self._write("use_time_alignment 2", timeout)
async def upmixer_set(
self, mode: const.UpmixerMode, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the upmixer mode. See `const.UpmixerMode` for available options
and descriptions.
"""
await self._write(f"remapping_mode {mode.value}", timeout)
async def volume_adjust(
self, delta: int | float, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Adjust the volume by a relative dB value (float).
"""
await self._write(f"dvolume {delta}", timeout)
async def volume_down(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Lower the volume by 0.5 dB
"""
await self.volume_adjust(-0.5, timeout)
async def volume_percentage_set(self, percentage: float):
"""
Set the volume based on a percentage.
"""
if not (0 <= percentage <= 100):
raise ValueError("Percentage must be between 0 and 100")
# Calculate the corresponding volume level from the percentage
volume = (
(percentage / 100) * (self.VOLUME_MAX - self.VOLUME_MIN)
) + self.VOLUME_MIN
volume = round(volume, 1)
await self.volume_set(volume)
async def volume_set(
self, db: int | float, timeout: int | float | None = USE_DEFAULT_TIMEOUT
):
"""
Set the volume to an absolute dB value.
"""
await self._write(f"volume {db}", timeout)
async def volume_ramp(
self,
db: int | float,
duration: int,
timeout: int | float | None = USE_DEFAULT_TIMEOUT,
):
"""
Ramp the volume to an absolute dB value (float) over a number of milliseconds (int).
"""
await self._write(f"volume_ramp {db} {duration}", timeout)
async def volume_up(self, timeout: int | float | None = USE_DEFAULT_TIMEOUT):
"""
Raise the volume by 0.5 dB
"""
await self.volume_adjust(0.5, timeout)
# --------------------------
# Utility
# --------------------------
async def _listen(
self,
read_timeout: float = 30.0,
read_backoff: float = 1.0,
reconnect: bool = True,
reconnect_timeout: float = 2.0,
reconnect_backoff: float = 2.0,
):
"""
Listen for messages and sync internal state
This method will automatically reconnect when necessary if `reconnect`
is set to `True`.
"""
try:
while True:
try:
await self._read(read_timeout)
except asyncio.TimeoutError:
self.logger.debug(
f"Read operation timed out, trying again in {read_backoff} seconds"
)
await asyncio.sleep(read_backoff)
except (exceptions.NotConnectedError, EOFError, OSError) as e:
if reconnect:
self.logger.debug(
f"Unable to read message from Trinnov Altitude, reconnecting...: {e}",
)
try:
await self.reconnect(timeout=reconnect_timeout)
except (
asyncio.TimeoutError,
exceptions.ConnectionTimeoutError,
exceptions.ConnectionFailedError,
) as e:
self.logger.debug(
f"Trinnov Altitude reconnect failed, trying again in {reconnect_backoff} seconds...: {e}"
)
await asyncio.sleep(reconnect_backoff)
else:
raise e
except asyncio.CancelledError:
self.logger.debug(
"Trinnov Altitude listen task received cancel, shutting down..."
)
def _process_message(self, raw_message: str) -> messages.Message: # noqa: C901
"""Receive a single message off of the socket and process it."""
print(raw_message)
message = messages.message_factory(raw_message)
if isinstance(message, messages.AudiosyncMessage):
self.audiosync = message.mode
elif isinstance(message, messages.BypassMessage):
self.bypass = message.state
elif isinstance(message, messages.CurrentPresetMessage):
self.preset = self.presets.get(message.index)
elif isinstance(message, messages.CurrentSourceFormat):
self.source_format = message.format
elif isinstance(message, messages.CurrentSourceMessage):
self.source = self.sources.get(message.index)
elif isinstance(message, messages.DecoderMessage):
self.decoder = message.decoder
self.upmixer = message.upmixer
elif isinstance(message, messages.DimMessage):
self.dim = message.state
elif isinstance(message, messages.ErrorMessage):
self.logger.error(
f"Received error message from Trinnov Altitude: {message.error}"
)
elif isinstance(message, messages.PresetMessage):
self.presets[message.index] = message.name
elif isinstance(message, messages.PresetsClearMessage):
self.presets = {}
elif isinstance(message, messages.MuteMessage):
self.mute = message.state
elif isinstance(message, messages.SourceMessage):
self.sources[message.index] = message.name
elif isinstance(message, messages.SourcesClearMessage):
self.sources = {}
elif isinstance(message, messages.SamplingRateMessage):
self.sampling_rate = message.rate
elif isinstance(message, messages.VolumeMessage):
self.volume = message.volume
elif isinstance(message, messages.WelcomeMessage):
self.version = message.version
self.id = message.id
if message is not None:
for callback in self._callbacks:
callback("received_message", message)
if (
not self._initial_sync.is_set()
and self.id is not None
and self.presets != {}
and self.sources != {}
and self.version is not None
):
self._initial_sync.set()
return message
async def _read(
self, timeout: int | float | None = USE_DEFAULT_TIMEOUT
) -> None | messages.Message:
"""Read a single raw message off of the socket"""
if self._reader is None:
raise exceptions.NotConnectedError()
if timeout is self.USE_DEFAULT_TIMEOUT:
timeout = self.timeout
raw_message = await asyncio.wait_for(self._reader.readline(), timeout)
if raw_message == b"":
self.logger.debug(
"Received EOF from Trinnov Altitude, closing connection..."
)
await self.disconnect()
raise exceptions.NotConnectedError()
else:
raw_message = raw_message.decode().rstrip()
self.logger.debug(f"Received message from Trinnov Altitude: {raw_message}")
return self._process_message(raw_message)
async def _write(self, message: str, timeout: float | None):
"""Write a single message to the socket"""
if self._writer is None:
raise exceptions.NotConnectedError()
if timeout is self.USE_DEFAULT_TIMEOUT:
timeout = self.timeout
if not message.endswith("\n"):
message += "\n"
message_bytes = message.encode(self.ENCODING)
self._writer.write(message_bytes)
try:
await asyncio.wait_for(self._writer.drain(), timeout=timeout)
self.logger.debug(f"Sent to Trinnov Altitude: {message.rstrip()}")
except OSError as e:
self.logger.debug(
f"Encountered connection error while writing to Trinnov Altitude, closing connection...: {e}"
)
await self.disconnect()
raise exceptions.NotConnectedError()