44
55import os
66import platform
7+ import time
78from typing import TYPE_CHECKING
89
910from zeroconf import IPVersion
1011
1112from music_assistant .helpers .process import check_output
12- from music_assistant .providers .airplay .constants import BROKEN_RAOP_MODELS
13+ from music_assistant .providers .airplay .constants import BROKEN_RAOP_MODELS , StreamingProtocol
1314
1415if TYPE_CHECKING :
1516 from zeroconf .asyncio import AsyncServiceInfo
1617
18+ # NTP epoch delta: difference between Unix epoch (1970) and NTP epoch (1900)
19+ NTP_EPOCH_DELTA = 0x83AA7E80 # 2208988800 seconds
20+
1721
1822def convert_airplay_volume (value : float ) -> int :
1923 """Remap AirPlay Volume to 0..100 scale."""
@@ -25,7 +29,7 @@ def convert_airplay_volume(value: float) -> int:
2529 return int (portion + normal_min )
2630
2731
28- def get_model_info (info : AsyncServiceInfo ) -> tuple [str , str ]:
32+ def get_model_info (info : AsyncServiceInfo ) -> tuple [str , str ]: # noqa: PLR0911
2933 """Return Manufacturer and Model name from mdns info."""
3034 manufacturer = info .decoded_properties .get ("manufacturer" )
3135 model = info .decoded_properties .get ("model" )
@@ -57,13 +61,35 @@ def get_model_info(info: AsyncServiceInfo) -> tuple[str, str]:
5761 return ("Apple" , "Apple TV 4K Gen2" )
5862 if model == "AppleTV14,1" :
5963 return ("Apple" , "Apple TV 4K Gen3" )
64+ if model == "UPL-AMP" :
65+ return ("Ubiquiti Inc." , "UPL-AMP" )
6066 if "AirPort" in model :
6167 return ("Apple" , "AirPort Express" )
6268 if "AudioAccessory" in model :
6369 return ("Apple" , "HomePod" )
6470 if "AppleTV" in model :
6571 model = "Apple TV"
6672 manufacturer = "Apple"
73+ # Detect Mac devices (Mac mini, MacBook, iMac, etc.)
74+ # Model identifiers like: Mac16,11, MacBookPro18,3, iMac21,1
75+ if model .startswith (("Mac" , "iMac" )):
76+ # Parse Mac model to friendly name
77+ if model .startswith ("MacBookPro" ):
78+ return ("Apple" , f"MacBook Pro ({ model } )" )
79+ if model .startswith ("MacBookAir" ):
80+ return ("Apple" , f"MacBook Air ({ model } )" )
81+ if model .startswith ("MacBook" ):
82+ return ("Apple" , f"MacBook ({ model } )" )
83+ if model .startswith ("iMac" ):
84+ return ("Apple" , f"iMac ({ model } )" )
85+ if model .startswith ("Macmini" ):
86+ return ("Apple" , f"Mac mini ({ model } )" )
87+ if model .startswith ("MacPro" ):
88+ return ("Apple" , f"Mac Pro ({ model } )" )
89+ if model .startswith ("MacStudio" ):
90+ return ("Apple" , f"Mac Studio ({ model } )" )
91+ # Generic Mac device (e.g. Mac16,11 for Mac mini M4)
92+ return ("Apple" , f"Mac ({ model } )" )
6793
6894 return (manufacturer or "AirPlay" , model )
6995
@@ -89,17 +115,43 @@ def is_broken_raop_model(manufacturer: str, model: str) -> bool:
89115 return False
90116
91117
92- async def get_cliraop_binary () -> str :
93- """Find the correct raop/airplay binary belonging to the platform."""
118+ async def get_cli_binary (protocol : StreamingProtocol ) -> str :
119+ """Find the correct raop/airplay binary belonging to the platform.
120+
121+ Args:
122+ protocol: The streaming protocol (RAOP or AIRPLAY2)
123+
124+ Returns:
125+ Path to the CLI binary
94126
95- async def check_binary (cliraop_path : str ) -> str | None :
127+ Raises:
128+ RuntimeError: If the binary cannot be found
129+ """
130+
131+ async def check_binary (cli_path : str ) -> str | None :
96132 try :
97- returncode , output = await check_output (
98- cliraop_path ,
99- "-check" ,
100- )
101- if returncode == 0 and output .strip ().decode () == "cliraop check" :
102- return cliraop_path
133+ if protocol == StreamingProtocol .RAOP :
134+ args = [
135+ cli_path ,
136+ "-check" ,
137+ ]
138+ passing_output = "cliraop check"
139+ else :
140+ config_file = os .path .join (os .path .dirname (__file__ ), "bin" , "cliap2.conf" )
141+ args = [
142+ cli_path ,
143+ "--testrun" ,
144+ "--config" ,
145+ config_file ,
146+ ]
147+
148+ returncode , output = await check_output (* args )
149+ if (
150+ protocol == StreamingProtocol .RAOP
151+ and returncode == 0
152+ and output .strip ().decode () == passing_output
153+ ) or (protocol == StreamingProtocol .AIRPLAY2 and returncode == 0 ):
154+ return cli_path
103155 except OSError :
104156 pass
105157 return None
@@ -108,10 +160,125 @@ async def check_binary(cliraop_path: str) -> str | None:
108160 system = platform .system ().lower ().replace ("darwin" , "macos" )
109161 architecture = platform .machine ().lower ()
110162
163+ if protocol == StreamingProtocol .RAOP :
164+ package = "cliraop"
165+ elif protocol == StreamingProtocol .AIRPLAY2 :
166+ package = "cliap2"
167+ else :
168+ raise RuntimeError (f"Unsupported streaming protocol requested: { protocol } " )
169+
111170 if bridge_binary := await check_binary (
112- os .path .join (base_path , f"cliraop -{ system } -{ architecture } " )
171+ os .path .join (base_path , f"{ package } -{ system } -{ architecture } " )
113172 ):
114173 return bridge_binary
115174
116- msg = f"Unable to locate RAOP Play binary for { system } /{ architecture } "
175+ msg = (
176+ f"Unable to locate { protocol .name } CLI stream binary { package } for { system } /{ architecture } "
177+ )
117178 raise RuntimeError (msg )
179+
180+
181+ def get_ntp_timestamp () -> int :
182+ """
183+ Get current NTP timestamp (64-bit).
184+
185+ Returns:
186+ int: 64-bit NTP timestamp (upper 32 bits = seconds, lower 32 bits = fraction)
187+ """
188+ # Get current Unix timestamp with microsecond precision
189+ current_time = time .time ()
190+
191+ # Split into seconds and microseconds
192+ seconds = int (current_time )
193+ microseconds = int ((current_time - seconds ) * 1_000_000 )
194+
195+ # Convert to NTP epoch (add offset from 1970 to 1900)
196+ ntp_seconds = seconds + NTP_EPOCH_DELTA
197+
198+ # Convert microseconds to NTP fraction (2^32 parts per second)
199+ # fraction = (microseconds * 2^32) / 1_000_000
200+ ntp_fraction = int ((microseconds << 32 ) / 1_000_000 )
201+
202+ # Combine into 64-bit value
203+ return (ntp_seconds << 32 ) | ntp_fraction
204+
205+
206+ def ntp_to_seconds_fraction (ntp_timestamp : int ) -> tuple [int , int ]:
207+ """
208+ Split NTP timestamp into seconds and fraction components.
209+
210+ Args:
211+ ntp_timestamp: 64-bit NTP timestamp
212+
213+ Returns:
214+ tuple: (seconds, fraction)
215+ """
216+ seconds = ntp_timestamp >> 32
217+ fraction = ntp_timestamp & 0xFFFFFFFF
218+ return seconds , fraction
219+
220+
221+ def ntp_to_unix_time (ntp_timestamp : int ) -> float :
222+ """
223+ Convert NTP timestamp to Unix timestamp (float).
224+
225+ Args:
226+ ntp_timestamp: 64-bit NTP timestamp
227+
228+ Returns:
229+ float: Unix timestamp (seconds since 1970-01-01)
230+ """
231+ seconds = ntp_timestamp >> 32
232+ fraction = ntp_timestamp & 0xFFFFFFFF
233+
234+ # Convert back to Unix epoch
235+ unix_seconds = seconds - NTP_EPOCH_DELTA
236+
237+ # Convert fraction to microseconds
238+ microseconds = (fraction * 1_000_000 ) >> 32
239+
240+ return unix_seconds + (microseconds / 1_000_000 )
241+
242+
243+ def unix_time_to_ntp (unix_timestamp : float ) -> int :
244+ """
245+ Convert Unix timestamp (float) to NTP timestamp.
246+
247+ Args:
248+ unix_timestamp: Unix timestamp (seconds since 1970-01-01)
249+
250+ Returns:
251+ int: 64-bit NTP timestamp
252+ """
253+ seconds = int (unix_timestamp )
254+ microseconds = int ((unix_timestamp - seconds ) * 1_000_000 )
255+
256+ # Convert to NTP epoch
257+ ntp_seconds = seconds + NTP_EPOCH_DELTA
258+
259+ # Convert microseconds to NTP fraction
260+ ntp_fraction = int ((microseconds << 32 ) / 1_000_000 )
261+
262+ return (ntp_seconds << 32 ) | ntp_fraction
263+
264+
265+ def add_seconds_to_ntp (ntp_timestamp : int , seconds : float ) -> int :
266+ """
267+ Add seconds to an NTP timestamp.
268+
269+ Args:
270+ ntp_timestamp: 64-bit NTP timestamp
271+ seconds: Number of seconds to add (can be fractional)
272+
273+ Returns:
274+ int: New NTP timestamp with seconds added
275+ """
276+ # Extract whole seconds and fraction
277+ whole_seconds = int (seconds )
278+ fraction = seconds - whole_seconds
279+
280+ # Convert to NTP format (upper 32 bits = seconds, lower 32 bits = fraction)
281+ ntp_seconds = whole_seconds << 32
282+ ntp_fraction = int (fraction * (1 << 32 ))
283+
284+ return ntp_timestamp + ntp_seconds + ntp_fraction
0 commit comments