99from zeroconf import ServiceStateChange
1010from zeroconf import Zeroconf
1111
12+ from ..utils .network .broadcast import BARE_LOCAL_LABEL
1213from ..utils .network .broadcast import EVENT_ADD_INSTANCE
1314from ..utils .network .broadcast import EVENT_REGISTER_INSTANCE
1415from ..utils .network .broadcast import EVENT_REMOVE_INSTANCE
2122from ..utils .network .broadcast import KolibriInstance
2223from ..utils .network .broadcast import KolibriInstanceListener
2324from ..utils .network .broadcast import LOCAL_DOMAIN
25+ from ..utils .network .broadcast import LOCAL_NAME_BARE
26+ from ..utils .network .broadcast import LOCAL_NAME_DEVICE
2427from ..utils .network .broadcast import NetworkDiscoveryBackend
2528from ..utils .network .broadcast import SERVICE_TTL
2629from ..utils .network .broadcast import SERVICE_TYPE
@@ -323,6 +326,8 @@ def setUp(self):
323326 enqueue_patcher .start ()
324327 self .addCleanup (enqueue_patcher .stop )
325328 self .transport = ZeroconfNetworkDiscovery ()
329+ # Captures the transport's local-name events (published on its own bus).
330+ self .listener = self .transport .add_listener (KolibriTestInstanceListener )
326331 self .on_add = mock .Mock ()
327332 self .on_update = mock .Mock ()
328333 self .on_remove = mock .Mock ()
@@ -331,12 +336,23 @@ def setUp(self):
331336 )
332337 get_all_addresses_patcher .start ()
333338 self .addCleanup (get_all_addresses_patcher .stop )
339+ # Default to "no default route" so tests don't open a real socket and
340+ # address selection falls back to the LAN-filtered addresses. Tests
341+ # exercising the outgoing-interface preference override this.
334342 outgoing_patcher = mock .patch (
335343 BROADCAST_MODULE + "get_outgoing_interface_address" , return_value = None
336344 )
337- outgoing_patcher .start ()
345+ self . mock_outgoing_interface_address = outgoing_patcher .start ()
338346 self .addCleanup (outgoing_patcher .stop )
339347
348+ def _register_with_device_name (self , device_name ):
349+ self .transport .zeroconf = self .zeroconf
350+ self .instance .device_info = {"device_name" : device_name }
351+ self .instance .to_service_info .return_value = mock .Mock (spec_set = ServiceInfo )(
352+ "primary"
353+ )
354+ self .transport .register (self .instance )
355+
340356 @mock .patch (BROADCAST_MODULE + "logger.info" )
341357 @mock .patch (BROADCAST_MODULE + "Zeroconf" )
342358 def test_register_opens_zeroconf_and_registers (self , mock_zeroconf , mock_logger ):
@@ -383,6 +399,181 @@ def test_register_rename(self, mock_logger):
383399 service_info_unique , ttl = SERVICE_TTL
384400 )
385401
402+ @mock .patch (BROADCAST_MODULE + "logger.info" )
403+ def test_register__local_names (self , mock_logger ):
404+ self .transport .zeroconf = self .zeroconf
405+ service_info = mock .Mock (spec_set = ServiceInfo )("test" )
406+ self .instance .to_service_info .return_value = service_info
407+ self .transport .register (self .instance )
408+ bare_label , bare_service = self .transport .local_names [LOCAL_NAME_BARE ]
409+ self .assertEqual (BARE_LOCAL_LABEL , bare_label )
410+ self .assertEqual ("kolibri.local." , bare_service .server )
411+ self .assertEqual (socket .inet_aton (MOCK_LAN_IP ), bare_service .address )
412+ self .assertNotIn (LOCAL_NAME_DEVICE , self .transport .local_names )
413+
414+ @mock .patch (BROADCAST_MODULE + "get_all_addresses" )
415+ @mock .patch (BROADCAST_MODULE + "logger.info" )
416+ def test_register__local_names_lan_address_selection (
417+ self , mock_logger , mock_get_all_addresses
418+ ):
419+ self .transport .zeroconf = self .zeroconf
420+ service_info = mock .Mock (spec_set = ServiceInfo )("test" )
421+ self .instance .to_service_info .return_value = service_info
422+ cases = [
423+ (
424+ [MOCK_CGNAT_IP , MOCK_LINK_LOCAL_IP , MOCK_LAN_IP ],
425+ socket .inet_aton (MOCK_LAN_IP ),
426+ ),
427+ ([MOCK_CGNAT_IP ], None ),
428+ ]
429+ for addresses , expected_address in cases :
430+ mock_get_all_addresses .return_value = addresses
431+ self .transport .register (self .instance )
432+ _ , bare_service = self .transport .local_names [LOCAL_NAME_BARE ]
433+ self .assertEqual (expected_address , bare_service .address )
434+
435+ @mock .patch (BROADCAST_MODULE + "get_all_addresses" )
436+ @mock .patch (BROADCAST_MODULE + "logger.info" )
437+ def test_register__prefers_outgoing_interface_address (
438+ self , mock_logger , mock_get_all_addresses
439+ ):
440+ # On a multi-homed host both RFC1918 addresses survive the LAN filter,
441+ # so we must advertise the default-route interface, not whichever one
442+ # sorts first. Reproduces the QA-reported case where a Docker-bridge
443+ # address was handed to LAN peers that couldn't reach it.
444+ self .transport .zeroconf = self .zeroconf
445+ self .instance .to_service_info .return_value = mock .Mock (spec_set = ServiceInfo )(
446+ "primary"
447+ )
448+ mock_get_all_addresses .return_value = [MOCK_SECONDARY_LAN_IP , MOCK_LAN_IP ]
449+ self .mock_outgoing_interface_address .return_value = MOCK_LAN_IP
450+ self .transport .register (self .instance )
451+ _ , bare_service = self .transport .local_names [LOCAL_NAME_BARE ]
452+ self .assertEqual (socket .inet_aton (MOCK_LAN_IP ), bare_service .address )
453+
454+ @mock .patch (BROADCAST_MODULE + "get_all_addresses" )
455+ @mock .patch (BROADCAST_MODULE + "logger.info" )
456+ def test_register__falls_back_when_outgoing_not_lan_reachable (
457+ self , mock_logger , mock_get_all_addresses
458+ ):
459+ # If the outgoing interface isn't LAN-reachable (e.g. a VPN default
460+ # route filtered out as CGNAT), fall back to a LAN-filtered address
461+ # rather than advertising the unreachable one.
462+ self .transport .zeroconf = self .zeroconf
463+ self .instance .to_service_info .return_value = mock .Mock (spec_set = ServiceInfo )(
464+ "primary"
465+ )
466+ mock_get_all_addresses .return_value = [MOCK_CGNAT_IP , MOCK_LAN_IP ]
467+ self .mock_outgoing_interface_address .return_value = MOCK_CGNAT_IP
468+ self .transport .register (self .instance )
469+ _ , bare_service = self .transport .local_names [LOCAL_NAME_BARE ]
470+ self .assertEqual (socket .inet_aton (MOCK_LAN_IP ), bare_service .address )
471+
472+ @mock .patch (BROADCAST_MODULE + "logger.info" )
473+ def test_register__device_name_alias (self , mock_logger ):
474+ self ._register_with_device_name ("Tony's Laptop" )
475+ device_label , device_service = self .transport .local_names [LOCAL_NAME_DEVICE ]
476+ self .assertEqual ("tonyslaptop" , device_label )
477+ self .assertEqual ("tonyslaptop.local." , device_service .server )
478+
479+ @mock .patch (BROADCAST_MODULE + "logger.info" )
480+ def test_register__device_name_alias_empty_slug (self , mock_logger ):
481+ self ._register_with_device_name (" " )
482+ self .assertNotIn (LOCAL_NAME_DEVICE , self .transport .local_names )
483+
484+ @mock .patch (BROADCAST_MODULE + "logger.info" )
485+ def test_register__local_name_conflict_skipped (self , mock_logger ):
486+ # These aliases make no attempt to stay unique: if the name is already
487+ # claimed on the network, we skip ours rather than renaming or crashing
488+ # the whole broadcast.
489+ self .transport .zeroconf = self .zeroconf
490+ self .instance .to_service_info .return_value = mock .Mock (spec_set = ServiceInfo )(
491+ "primary"
492+ )
493+ # primary registers fine; the bare alias is already claimed
494+ self .zeroconf .register_service .side_effect = [None , NonUniqueNameException ()]
495+ self .transport .register (self .instance ) # must not raise
496+ self .assertNotIn (LOCAL_NAME_BARE , self .transport .local_names )
497+ self .assertEqual ([], self .transport .local_hostnames )
498+
499+ def test_local_hostnames (self ):
500+ self ._register_with_device_name ("My Device" )
501+ self .assertEqual (
502+ {"kolibri.local" , "mydevice.local" },
503+ set (self .transport .local_hostnames ),
504+ )
505+ self .assertEqual (
506+ {"kolibri.local" , "mydevice.local" },
507+ set (self .listener .mock .update_local_names .call_args [0 ][0 ]),
508+ )
509+
510+ @mock .patch (BROADCAST_MODULE + "get_all_addresses" )
511+ @mock .patch (BROADCAST_MODULE + "logger.info" )
512+ def test_renew__local_names_follow_lan_address_change (
513+ self , mock_logger , mock_get_all_addresses
514+ ):
515+ mock_get_all_addresses .return_value = [MOCK_LAN_IP ]
516+ self ._register_with_device_name ("Some Name" )
517+
518+ new_lan_ip = "192.168.1.9"
519+ mock_get_all_addresses .return_value = [new_lan_ip ]
520+ self .transport .renew ()
521+
522+ _ , bare_service = self .transport .local_names [LOCAL_NAME_BARE ]
523+ self .assertEqual (socket .inet_aton (new_lan_ip ), bare_service .address )
524+
525+ @mock .patch (BROADCAST_MODULE + "logger.info" )
526+ def test_renew__device_name_changed (self , mock_logger ):
527+ self ._register_with_device_name ("Old Name" )
528+ old_service = self .transport .local_names [LOCAL_NAME_DEVICE ][1 ]
529+
530+ self .instance .device_info = {"device_name" : "New Name" }
531+ self .transport .renew ()
532+
533+ self .zeroconf .unregister_service .assert_any_call (old_service )
534+ new_label , new_service = self .transport .local_names [LOCAL_NAME_DEVICE ]
535+ self .assertEqual ("newname" , new_label )
536+ self .assertEqual ("newname.local." , new_service .server )
537+ self .zeroconf .register_service .assert_any_call (new_service , ttl = new_service .ttl )
538+
539+ @mock .patch (BROADCAST_MODULE + "logger.info" )
540+ def test_renew__device_name_changed_to_empty_slug (self , mock_logger ):
541+ self ._register_with_device_name ("Old Name" )
542+ old_service = self .transport .local_names [LOCAL_NAME_DEVICE ][1 ]
543+
544+ self .instance .device_info = {"device_name" : " " }
545+ self .transport .renew ()
546+
547+ self .zeroconf .unregister_service .assert_any_call (old_service )
548+ self .assertNotIn (LOCAL_NAME_DEVICE , self .transport .local_names )
549+
550+ @mock .patch (BROADCAST_MODULE + "logger.info" )
551+ def test_renew__device_name_unchanged (self , mock_logger ):
552+ self ._register_with_device_name ("Same Name" )
553+ self .zeroconf .register_service .reset_mock ()
554+ self .zeroconf .unregister_service .reset_mock ()
555+
556+ self .transport .renew ()
557+
558+ self .zeroconf .unregister_service .assert_not_called ()
559+ self .zeroconf .register_service .assert_not_called ()
560+ # 1 for the primary instance (pre-existing, unchanged renew() logic)
561+ # + 2 for the bare and device aliases, both re-announced with fresh port/ttl
562+ self .assertEqual (3 , self .zeroconf .update_service .call_count )
563+
564+ def test_unregister__local_names (self ):
565+ self .instance .service_info = mock .Mock (spec_set = ServiceInfo )("test" )
566+ self ._register_with_device_name ("Some Name" )
567+ bare_service = self .transport .local_names [LOCAL_NAME_BARE ][1 ]
568+ device_service = self .transport .local_names [LOCAL_NAME_DEVICE ][1 ]
569+
570+ self .transport .unregister ()
571+
572+ self .zeroconf .unregister_service .assert_any_call (bare_service )
573+ self .zeroconf .unregister_service .assert_any_call (device_service )
574+ self .assertEqual ({}, self .transport .local_names )
575+ self .listener .mock .update_local_names .assert_called_with ([])
576+
386577 @pytest .mark .skipif (ZEROCONF_NEEDS_UPDATE , reason = "Needs updated Zeroconf" )
387578 @mock .patch (BROADCAST_MODULE + "ZeroconfNetworkDiscovery.renew" )
388579 def test_update_returns_false_when_addresses_unchanged (self , mock_renew ):
0 commit comments