Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Getting service info on unregistration #1235

Open
kiplingw opened this issue Aug 27, 2023 · 23 comments
Open

Getting service info on unregistration #1235

kiplingw opened this issue Aug 27, 2023 · 23 comments

Comments

@kiplingw
Copy link

Hey everyone,

I note that the listener's add_service() callback has available to it the coordinates for the custom service that I am tracking. Whenever it comes online this callback can use get_service_info() to get the host and port.

I note that the listener's remove_service() callback cannot try to obtain the host and port of the service that just went offline by querying get_service_info(). The latter will return None.

My application would like to track a particular service and update a GUI accordingly. When the service comes online it's easy to add the host and port to some kind of GUI list, but I cannot figure out how to determine which server just went down when remove_service() callback is invoked.

How can I get this information?

Maybe I'm going about this all the wrong way, but presumably this is a common issue others have had in the past.

@bdraco
Copy link
Member

bdraco commented Sep 3, 2023

You need to watch for Removed callbacks. Check the examples

@kiplingw
Copy link
Author

kiplingw commented Sep 3, 2023

Hey @bdraco. Thanks for responding. As I mentioned, as far as I understand it, the remove_service() callback doesn't have enough information available to it to obtain the host and port of the service that just went down when it queries get_service_info(). The latter will return None.

I looked at the examples, but perhaps I'm missing something?

@bdraco
Copy link
Member

bdraco commented Sep 3, 2023

Once a service has been removed it’s info is gone and there is no responder on the network anymore to answer questions to fill ServiceInfo, you’ll have to save it when it’s added if you need it later

@kiplingw
Copy link
Author

kiplingw commented Sep 3, 2023

No I get that, but even if you keep a list as each service is added, you don't know which one of them was removed when you get the notification - only that one of them has been.

@bdraco
Copy link
Member

bdraco commented Sep 3, 2023

The name is in the remove callback so you can keep a dict of the service info by name when it's added

@kiplingw
Copy link
Author

kiplingw commented Sep 3, 2023

Yes, but as I mentioned I'm only tracking one type of custom service. So all of the add and remove notifications are for the same type of service, but not necessarily the same server.

@bdraco
Copy link
Member

bdraco commented Sep 3, 2023

You probably need to install your own RecordUpdateListener and watch records as they come in and are removed.

@kiplingw
Copy link
Author

kiplingw commented Sep 3, 2023

Thanks @bdraco, but I'm still struggling. I looked up what I could find on RecordUpdateListener and I'm not clear if I need to change the following to derive from that class?

# Zeroconf local network service listener with callbacks to discover MyService
#  server...
class LocalNetworkServiceListener:

    # Constructor...
    def __init__(self, logging=True, add_callback=None, remove_callback=None):
        self._logging           = logging
        self._add_callback      = add_callback
        self._remove_callback   = remove_callback
        self.found_event        = threading.Event()
        self._servers           = []
        self._name_regex        = r'^MyService(\s\#\d*)?\._http(s)?\._tcp\.local\.$'
        self._service_tls_regex = r'^MyService(\s\#\d*)?\._https\._tcp\.local\.$'

    # Service online callback...
    def add_service(self, zeroconf, type, name):

        # Which service?
        info = zeroconf.get_service_info(type, name)

        # Found server...
        if re.match(self._name_regex, name) is not None:

            # No network service information available...
            if info is None:

                # Log, if requested...
                if self._logging:
                    print(_(F"MyService server {colored(_('online'), 'green')} (no service information available)"))

                # Nothing more to do...
                return

            # Extract host and port...
            host = info.parsed_addresses()[0]
            port = info.port
            tls  = bool(re.match(self._service_tls_regex, name) is not None)

            # If not already known...
            if not self._servers.count((host,port,tls)):

                # Add to available list...
                self._servers.append((host,port,tls))

                # Invoke user callback, if provided...
                if self._add_callback is not None:
                    self._add_callback(host, port, tls)

            # Prepare message to notify user...
            if re.match(self._service_tls_regex, name) is not None:
                message = _(F"MyService server {colored(_('online'), 'green')} at {host}:{port} ({colored(_('TLS'), 'green')})")
            else:
                message = _(F"MyService server {colored(_('online'), 'green')} at {host}:{port} ({colored(_('TLS disabled'), 'red')})")

            # Log, if requested...
            if self._logging:
                print(message)

            # Alert any waiting threads at least one server is found...
            self.found_event.set()

    # Service went offline callback...
    def remove_service(self, zeroconf, type, name):

        # Which service?
        info = zeroconf.get_service_info(type, name)

        # Not a MyService server...
        if re.match(self._name_regex, name) is None:
            return

        # No network service information available...
        if info is None:

            # Log, if requested...
            if self._logging:
                print(_(F"MyService server {colored(_('offline'), 'yellow')} (no service information available)"))

            # Invoke user's callback, if requested...
#            if self._remove_callback is not None:
#                self._remove_callback(host, port)

            # Done...
            return

        # Extract host and port...
        host = info.parsed_addresses()[0]
        port = info.port

        # Remove from available list...
        for server in self._servers:

            # If it was already there, remove from available list...
            if (server[0] is host) and (server[1] is port):
                self._servers.remove(server)

        # If nothing is available, clear event flag...
        if len(self._servers) == 0:
            self.found_event.clear()

    # Get server list of all servers found...
    def get_found(self):
        return self._servers

@bdraco
Copy link
Member

bdraco commented Sep 3, 2023

@kiplingw
Copy link
Author

kiplingw commented Sep 3, 2023

I'm sorry but it doesn't. It's not clear what you're directing me to. This looks like a method of a class derived from zeroconf.RecordUpdateListener? It's not clear to me what async_update_records is doing and how that relates to my problem.

@kiplingw
Copy link
Author

👋🏽

@bdraco
Copy link
Member

bdraco commented Apr 1, 2024

You'll have to cache the data when the service is added or updated, as once it's removed, the device is off-line and will not respond to requests

@kiplingw
Copy link
Author

kiplingw commented Apr 1, 2024

Right, but cache which data?

@bdraco
Copy link
Member

bdraco commented Apr 13, 2024

From your opening text, I expect you need to keep a map of service name to host/port and look it up in the dict when it goes offline

@kiplingw
Copy link
Author

@bdraco, as I mentioned above the remove notification doesn't tell you which host went offline. So even if you keep a cache of service, host, and port as they come online, you don't know which one of them went offline when they do. You're only given the name of the service.

@bdraco
Copy link
Member

bdraco commented Apr 13, 2024

You'll need to keep a map of name -> (host, port)

def remove_service(self, zeroconf, type, name):

The name of the service is in the remove_service

@kiplingw
Copy link
Author

That still doesn't address the problem. As I said above I have multiple machines advertising the same service on the network. Each machine has a different host and port. When the service is announced a listener sees the name of the service, its host, and port. When the service goes offline, the listener only knows that an instance of a service name is gone, but not which host and port.

@bdraco
Copy link
Member

bdraco commented Apr 13, 2024

Its a bit unconventional that you would have multiple different hosts published for the same service name that change frequently, but in that case you'll have to go with the RecordUpdateListener I originally suggested in #1235 (comment) and make an async_update_records which watches for a RecordUpdate to know when records to go away when they expire. You can check if the record is expired with is_expired

def async_add_listener(

def async_update_records(self, zc: 'Zeroconf', now: float_, records: List[RecordUpdate]) -> None:

@kiplingw
Copy link
Author

Thanks @bdraco. I'll give it a go when I have time and report back.

@sdbbs
Copy link

sdbbs commented Apr 19, 2024

I just ran into this problem, I will try to illustrate.

Its a bit unconventional that you would have multiple different hosts published for the same service name that change frequently

Well, I have a work setup, where I have (let's say) 10 Raspberry Pis, each of which runs an OLA server. And also, let's say I want to keep track of all of them - whether their OLA servers are online - in a table.

So, I run my class MyListener(ServiceListener) script, and have a corresponding printout print(f"add_service {name=} {type_=} {info=} {zc=}") or print(f"remove_service {name=} {type_=} {info=} {zc=}") in their respective handlers (and of course, a info = zc.get_service_info(type_, name) before the printouts.

So first I get this printout (formatting mine)

add_service name='OLA Server._http._tcp.local.' 
  type_='_http._tcp.local.' 
  info=ServiceInfo(
    type='_http._tcp.local.', 
    name='OLA Server._http._tcp.local.', 
    addresses=[b'\xc0\xa8\x01\x0f'], 
    port=9090, 
    weight=0, 
    priority=0, 
    server='my-rpi-03.local.', 
    properties={b'path': b'/'}, 
    interface_index=None) 
zc=<zeroconf._core.Zeroconf object at 0x000002772cf16d10>

Great - so I have an OLA Server running on device with hostname my-rpi-03, with IP address list(b'\xc0\xa8\x01\x0f') == [192, 168, 1, 15], listening on port 9090. So I can add this entry to my table:

Hostname IP address OLA port
my-rpi-03.local. 192.168.1.15 9090

Soon afterwards, I get this printout:

add_service name='OLA Server._http._tcp.local.' 
  type_='_http._tcp.local.' 
  info=ServiceInfo(
    type='_http._tcp.local.', 
    name='OLA Server._http._tcp.local.', 
    addresses=[b'\xc0\xa8\x01\x1b'], 
    port=9090, 
    weight=0, 
    priority=0, 
    server='my-rpi-04.local.', 
    properties={b'path': b'/'}, 
    interface_index=None) 
zc=<zeroconf._core.Zeroconf object at 0x000002772cf16d10>

Great again - so I also have an OLA Server running on device with hostname my-rpi-04, with IP address list(b'\xc0\xa8\x01\x1b') == [192, 168, 1, 27], listening again on port 9090. So I can add also this entry to my table:

Hostname IP address OLA port
my-rpi-03.local. 192.168.1.15 9090
my-rpi-04.local. 192.168.1.27 9090

Excellent - exactly what I want: a list of devices with their hostnames and IP addresses, offering the service I'm interested on, on a given port.

OK, now just for fun, I log in to my-rpi-04 over ssh, and shut down the OLA server with:

sudo service olad stop

Soon thereafter, I receive the remove_service printout:

remove_service name='OLA Server._http._tcp.local.' 
  type_='_http._tcp.local.' 
  info=None 
zc=<zeroconf._core.Zeroconf object at 0x000002772cf16d10>

Great - now I know that an OLA Server went offline - but which one? 192.168.1.15 or 192.168.1.27? Which entry do I remove from the table?

Looking at this:

you'll have to go with the RecordUpdateListener I originally suggested in #1235 (comment) and make an async_update_records which watches for a RecordUpdate to know when records to go away when they expire. You can check if the record is expired with is_expired

... I still cannot tell whether this RecordUpdate will have some sort of a unique identifier, relating it to a given hostname + IP address + port combination? Because if there isn't such an identifier, it is useless for the kind of "table tracking" I've just described above. But I guess, I'll have to take a look ...

In any case - if worse comes to worse, - I guess I could live with the following workaround: remove_service event comes in, we don't know from whom; so we just delete the entire table of online servers, and then somehow restart the entire Zeroconf discovery process, and populate the table anew - at least, when this process is done, the table should be accurate. But is there a command to restart the Zeroconf discovery process gracefully (at least, more gracefully than e.g. deleting the whole MyListener object, and re-instantiating it again? or exiting the app and restarting it?)

@kiplingw
Copy link
Author

Excellent illustration of the problem, @sdbbs. Thank you.

@sdbbs
Copy link

sdbbs commented Apr 19, 2024

OK, I found one workaround: given that in the context of this problem, we actually start by managing an online servers table, obviously, at first, we will get this table populated with entries advertising the service of interest (SOI) via add_service.

Then, let's say remove_service hits - as stated before, at this point, we do not know which device server went offlne; however, thanks to our initial goal to manage a table -- at this point, we do know which servers had previously been online, and we have a concrete, known, number of them in a list/table.

So then, the solution is: when remove_service hits, loop through all entries of the (previously) online servers table, and check if the specified port at the specified IP address is open; when we arrive at a device in the online servers list that does not respond at the port any more, then it is this device('s server) that went offline, and is the source of remove_service Zeroconf event; and so, it is this device entry that should be removed from the online servers list.

Here is an example I cooked up from the README example:

#!/usr/bin/env python3

from zeroconf import ServiceBrowser, ServiceListener, Zeroconf
import socket

SOI = "OLA Server._http._tcp.local." # Service Of Interest
online_servers_list = []

def socket_check_port(ip_address, port):
  """ inspired from https://stackoverflow.com/q/19196105 """
  s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  s.settimeout(0.5) # Timeout in case of port not open
  try:
    s.connect((ip_address, port))
    return True
  except:
    return False


class MyListener(ServiceListener):

  def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
    info = zc.get_service_info(type_, name)
    #print(f"update_service {name=} {type_=} {info=} {zc=}")

  def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
    info = zc.get_service_info(type_, name)
    #print(f"remove_service {name=} {type_=} {info=} {zc=}")
    if name == SOI:
      print("")
      print(f"remove_service {info=} - so iterate through previously live entries in online_servers_list, and check open ports on previously found servers")
      idx_to_remove = -1
      for idx, idict in enumerate(online_servers_list):
        ip_address, port = idict["ip_address"], idict["port"]
        is_port_open = socket_check_port(ip_address, port)
        print(f"remove_service list {idx=} : {ip_address=} {port=} {is_port_open=}")
        if not(is_port_open):
          print(f"NOTE: previously live server with list {idx=} has gone offline!")
          idx_to_remove = idx
          break
      if idx_to_remove>-1:
        # remove entry
        online_servers_list.pop(idx_to_remove)
      print(f"\nremove_service: {online_servers_list=}\n")
  def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
    info = zc.get_service_info(type_, name)
    #print(f"add_service {name=} {type_=} {info=} {zc=}")
    if name == SOI:
      ip_address = ".".join(map(str, list(info.addresses[0])))
      is_port_open = socket_check_port(ip_address, info.port)
      print(f"add_service {ip_address=} {info.port=} {is_port_open=}")
      tdict = {"hostname": info.server, "ip_address": ip_address, "port": info.port}
      entry_found = False
      for idict in online_servers_list:
        is_same_device = ( (tdict["hostname"] == idict["hostname"]) and (tdict["ip_address"] == idict["ip_address"]) )
        if is_same_device:
          entry_found = True
          break
      if not entry_found:
        online_servers_list.append(tdict)
      print(f"\nadd_service: {online_servers_list=}\n")


zeroconf = Zeroconf()
listener = MyListener()
browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener)
try:
  input("Press enter to exit...\n\n")
finally:
  zeroconf.close()

So I could only test with one RPi with OLA on the local network; but when I start the script with RPI OLA server online on local network, I get printouts:

add_service ip_address='192.168.1.15' info.port=9090 is_port_open=True

add_service: online_servers_list=[{'hostname': 'my-rpi-03.local.', 'ip_address': '192.168.1.15', 'port': 9090}]

Then I log in to the RPi, and shut down OLA server with

sudo service olad stop

... and soon afterwards I get this printout:

remove_service info=None - so iterate through previously live entries in online_servers_list, and check open p
orts on previously found servers
remove_service list idx=0 : ip_address='192.168.1.15' port=9090 is_port_open=False
NOTE: previously live server with list idx=0 has gone offline!

remove_service: online_servers_list=[]

For the kind of context I need this for (servers running on Raspberry Pis on local network, unencrypted) this seems to work - though I'm not sure if socket checking for open ports will work for HTTPS servers or similar .... so some more complicated "check open port" or "establish connection" or "ping" methods might be required there for this concept to work ...

This has not been extensively tested so YMMV, but at least with one device, - in principle - it seems to work.

@kiplingw
Copy link
Author

kiplingw commented Apr 19, 2024 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants