robot_lab-discovery 0.2.6

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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "zeroconf"
5
+
6
+ module RobotLab
7
+ module Discovery
8
+ class Advertiser
9
+ attr_reader :name, :port, :path, :hostname, :capabilities
10
+
11
+ def initialize(name:, port:, path:, hostname: Socket.gethostname, capabilities: [])
12
+ @name = name
13
+ @port = port
14
+ @path = path
15
+ @hostname = hostname
16
+ @capabilities = Array(capabilities).map(&:to_s)
17
+ @service = nil
18
+ @thread = nil
19
+ end
20
+
21
+ def start
22
+ @service = ZeroConf::Service.new(
23
+ SERVICE_TYPE,
24
+ @port,
25
+ @hostname,
26
+ instance_name: @name,
27
+ text: TxtRecord.encode(path: @path, capabilities: @capabilities),
28
+ subtypes: @capabilities.map { |c| "_#{RobotLab::Discovery.dns_label(c)}" }
29
+ )
30
+ @thread = Thread.new { @service.start }
31
+ self
32
+ end
33
+
34
+ def stop
35
+ @service&.stop
36
+ @thread&.join
37
+ @service = nil
38
+ @thread = nil
39
+ self
40
+ end
41
+
42
+ def started? = @thread&.alive? || false
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "resolv"
4
+ require "zeroconf"
5
+
6
+ module RobotLab
7
+ module Discovery
8
+ module Browser
9
+ def self.browse(timeout: 3)
10
+ ZeroConf.browse(SERVICE_TYPE, timeout:)
11
+ .filter_map { |r| parse_response(r) }
12
+ .uniq(&:name)
13
+ end
14
+
15
+ def self.find(name, timeout: 5)
16
+ deadline = Time.now + timeout
17
+ loop do
18
+ remaining = deadline - Time.now
19
+ return nil if remaining <= 0
20
+ result = browse(timeout: [remaining, 1].min).find { |r| r.name == name }
21
+ return result if result
22
+ end
23
+ end
24
+
25
+ def self.find_by_capability(capability, timeout: 3)
26
+ label = RobotLab::Discovery.dns_label(capability)
27
+ subtype = "_#{label}._sub.#{SERVICE_TYPE}"
28
+ ZeroConf.browse(subtype, timeout:)
29
+ .filter_map { |r| parse_response(r) }
30
+ .uniq(&:name)
31
+ end
32
+
33
+ def self.list_capabilities(timeout: 3)
34
+ browse(timeout:)
35
+ .flat_map(&:capabilities)
36
+ .uniq
37
+ .sort
38
+ end
39
+
40
+ def self.parse_response(response)
41
+ instance_name = nil
42
+ hostname = nil
43
+ port = nil
44
+ txt = {}
45
+
46
+ (response.answer + response.additional).each do |rname, _ttl, data|
47
+ case data
48
+ when Resolv::DNS::Resource::IN::SRV
49
+ port = data.port
50
+ hostname = data.target.to_s.delete_suffix(".")
51
+ instance_name = rname.to_s.split(".").first
52
+ when Resolv::DNS::Resource::IN::TXT
53
+ txt = TxtRecord.decode(data.strings)
54
+ end
55
+ end
56
+
57
+ return nil unless instance_name && hostname && port && txt[:path]
58
+ Result.new(
59
+ name: instance_name,
60
+ hostname:,
61
+ port:,
62
+ path: txt[:path],
63
+ capabilities: txt.fetch(:capabilities, [])
64
+ )
65
+ end
66
+ private_class_method :parse_response
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Discovery
5
+ SERVICE_TYPE = "_robot-lab._tcp.local."
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Discovery
5
+ Result = Data.define(:name, :hostname, :port, :path, :capabilities) do
6
+ def url = "http://#{hostname}:#{port}#{path}"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Discovery
5
+ module TxtRecord
6
+ # Short wire keys — every byte saved is more room for capability names.
7
+ # p = path (vs "path=" — saves 3 bytes)
8
+ # v = rl_version (vs "rl_version=" — saves 9 bytes)
9
+ # c = capabilities (vs "capabilities=" — saves 11 bytes)
10
+ PATH_KEY = "p"
11
+ VERSION_KEY = "v"
12
+ CAPABILITIES_KEY = "c"
13
+
14
+ # RFC 1035 §3.3.14: each string in a TXT record is length-prefixed with
15
+ # one byte, so the content of any single string is capped at 255 bytes.
16
+ MAX_STRING_BYTES = 255
17
+
18
+ # mDNS runs over UDP. Ethernet MTU (1500) minus IP/UDP/DNS headers leaves
19
+ # ~1472 bytes for the DNS payload. After accounting for the SRV, A/AAAA,
20
+ # and PTR records that accompany a service announcement, the TXT record
21
+ # wire size (1 length-prefix byte per string + content bytes) should stay
22
+ # under 1300 bytes to fit in a single unfragmented packet.
23
+ MAX_TOTAL_BYTES = 1300
24
+
25
+ # Bytes available for capability names + commas after key + "=" overhead.
26
+ MAX_CAPABILITIES_CONTENT = MAX_STRING_BYTES - CAPABILITIES_KEY.bytesize - 1 # 253
27
+
28
+ def self.encode(path:, capabilities: [])
29
+ record = [
30
+ "#{PATH_KEY}=#{path}",
31
+ "#{VERSION_KEY}=#{VERSION}"
32
+ ]
33
+ record << "#{CAPABILITIES_KEY}=#{Array(capabilities).join(",")}" unless Array(capabilities).empty?
34
+ validate!(record)
35
+ record
36
+ end
37
+
38
+ def self.decode(strings)
39
+ strings.each_with_object({}) do |entry, hash|
40
+ k, v = entry.split("=", 2)
41
+ next unless k && v
42
+ case k
43
+ when PATH_KEY then hash[:path] = v
44
+ when VERSION_KEY then hash[:rl_version] = v
45
+ when CAPABILITIES_KEY then hash[:capabilities] = v.split(",")
46
+ end
47
+ end
48
+ end
49
+
50
+ def self.validate!(strings)
51
+ strings.each do |s|
52
+ next unless s.bytesize > MAX_STRING_BYTES
53
+ raise Error,
54
+ "TXT record string exceeds #{MAX_STRING_BYTES} bytes " \
55
+ "(#{s.bytesize} bytes): #{s[0, 40].inspect}#{"..." if s.length > 40}"
56
+ end
57
+
58
+ # Wire size: each string occupies 1 length-prefix byte + its content.
59
+ wire_size = strings.sum { |s| 1 + s.bytesize }
60
+ if wire_size > MAX_TOTAL_BYTES
61
+ raise Error,
62
+ "TXT record wire size exceeds #{MAX_TOTAL_BYTES} bytes (#{wire_size} bytes). " \
63
+ "Shorten path or reduce the number/length of capabilities."
64
+ end
65
+
66
+ strings
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Discovery
5
+ VERSION = "0.2.6"
6
+ end
7
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "discovery/version"
4
+ require_relative "discovery/constants"
5
+ require_relative "discovery/txt_record"
6
+ require_relative "discovery/result"
7
+ require_relative "discovery/advertiser"
8
+ require_relative "discovery/browser"
9
+
10
+ module RobotLab
11
+ module Discovery
12
+ class Error < StandardError; end
13
+
14
+ def self.browse(timeout: 3) = Browser.browse(timeout:)
15
+ def self.find(name, timeout: 5) = Browser.find(name, timeout:)
16
+ def self.find_by_capability(cap, timeout: 3) = Browser.find_by_capability(cap, timeout:)
17
+ def self.list_capabilities(timeout: 3) = Browser.list_capabilities(timeout:)
18
+
19
+ # Converts a free-form capability string into a valid DNS label:
20
+ # downcased, non-alphanumeric runs replaced with hyphens, no leading/trailing hyphens.
21
+ # "Web Search" → "web-search", "NLP/Analysis" → "nlp-analysis"
22
+ def self.dns_label(capability)
23
+ capability.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
24
+ end
25
+ end
26
+ end
27
+
28
+ if defined?(RobotLab) && RobotLab.respond_to?(:register_extension)
29
+ RobotLab.register_extension(:discovery, RobotLab::Discovery)
30
+ end
data/mkdocs.yml ADDED
@@ -0,0 +1,117 @@
1
+ site_name: robot_lab-discovery
2
+ site_description: Zero-configuration mDNS/DNS-SD robot discovery for the RobotLab LLM agent framework
3
+ site_author: Dewayne VanHoozer
4
+ site_url: https://madbomber.github.io/robot_lab-discovery
5
+ copyright: Copyright &copy; 2026 Dewayne VanHoozer
6
+
7
+ repo_name: MadBomber/robot_lab-discovery
8
+ repo_url: https://github.com/MadBomber/robot_lab-discovery
9
+ edit_uri: edit/main/docs/
10
+ docs_dir: docs
11
+
12
+ theme:
13
+ name: material
14
+
15
+ palette:
16
+ - scheme: default
17
+ primary: cyan
18
+ accent: amber
19
+ toggle:
20
+ icon: material/brightness-7
21
+ name: Switch to dark mode
22
+
23
+ - scheme: slate
24
+ primary: cyan
25
+ accent: amber
26
+ toggle:
27
+ icon: material/brightness-4
28
+ name: Switch to light mode
29
+
30
+ font:
31
+ text: Roboto
32
+ code: Roboto Mono
33
+
34
+ icon:
35
+ repo: fontawesome/brands/github
36
+ logo: material/radar
37
+
38
+ features:
39
+ - navigation.instant
40
+ - navigation.tracking
41
+ - navigation.tabs
42
+ - navigation.tabs.sticky
43
+ - navigation.path
44
+ - navigation.indexes
45
+ - navigation.top
46
+ - navigation.footer
47
+ - toc.follow
48
+ - search.suggest
49
+ - search.highlight
50
+ - search.share
51
+ - header.autohide
52
+ - content.code.copy
53
+ - content.code.annotate
54
+ - content.tabs.link
55
+ - content.tooltips
56
+ - content.action.edit
57
+ - content.action.view
58
+
59
+ plugins:
60
+ - search:
61
+ separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
62
+
63
+ markdown_extensions:
64
+ - abbr
65
+ - admonition
66
+ - attr_list
67
+ - def_list
68
+ - footnotes
69
+ - md_in_html
70
+ - tables
71
+ - toc:
72
+ permalink: true
73
+ title: On this page
74
+ - pymdownx.betterem:
75
+ smart_enable: all
76
+ - pymdownx.caret
77
+ - pymdownx.details
78
+ - pymdownx.emoji:
79
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
80
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
81
+ - pymdownx.highlight:
82
+ anchor_linenums: true
83
+ line_spans: __span
84
+ pygments_lang_class: true
85
+ - pymdownx.inlinehilite
86
+ - pymdownx.magiclink:
87
+ repo_url_shorthand: true
88
+ user: MadBomber
89
+ repo: robot_lab-discovery
90
+ normalize_issue_symbols: true
91
+ - pymdownx.mark
92
+ - pymdownx.smartsymbols
93
+ - pymdownx.superfences:
94
+ custom_fences:
95
+ - name: mermaid
96
+ class: mermaid
97
+ format: !!python/name:pymdownx.superfences.fence_code_format
98
+ - pymdownx.tabbed:
99
+ alternate_style: true
100
+ - pymdownx.tasklist:
101
+ custom_checkbox: true
102
+ - pymdownx.tilde
103
+
104
+ extra:
105
+ social:
106
+ - icon: fontawesome/brands/github
107
+ link: https://github.com/MadBomber/robot_lab-discovery
108
+ name: robot_lab-discovery on GitHub
109
+ - icon: fontawesome/solid/gem
110
+ link: https://rubygems.org/gems/robot_lab-discovery
111
+ name: robot_lab-discovery on RubyGems
112
+
113
+ nav:
114
+ - Home: index.md
115
+ - Getting Started: getting_started.md
116
+ - How It Works: how_it_works.md
117
+ - API Reference: api_reference.md
@@ -0,0 +1,6 @@
1
+ module RobotLab
2
+ module Discovery
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: robot_lab-discovery
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.6
5
+ platform: ruby
6
+ authors:
7
+ - Dewayne VanHoozer
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: zeroconf
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ description: Allows RobotLab robots to advertise themselves and discover each other
27
+ on a local network using multicast DNS (mDNS/Bonjour/Avahi) via the zeroconf gem.
28
+ email:
29
+ - dvanhoozer@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".envrc"
35
+ - ".github/workflows/deploy-github-pages.yml"
36
+ - ".loki"
37
+ - ".quality/reek_baseline.txt"
38
+ - ".rubocop.yml"
39
+ - CHANGELOG.md
40
+ - CLAUDE.md
41
+ - LICENSE.txt
42
+ - README.md
43
+ - Rakefile
44
+ - docs/api_reference.md
45
+ - docs/getting_started.md
46
+ - docs/how_it_works.md
47
+ - docs/index.md
48
+ - examples/01_basic_usage.rb
49
+ - examples/common.rb
50
+ - lib/robot_lab/discovery.rb
51
+ - lib/robot_lab/discovery/advertiser.rb
52
+ - lib/robot_lab/discovery/browser.rb
53
+ - lib/robot_lab/discovery/constants.rb
54
+ - lib/robot_lab/discovery/result.rb
55
+ - lib/robot_lab/discovery/txt_record.rb
56
+ - lib/robot_lab/discovery/version.rb
57
+ - mkdocs.yml
58
+ - sig/robot_lab/discovery.rbs
59
+ homepage: https://github.com/MadBomber/robot_lab-discovery
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ homepage_uri: https://github.com/MadBomber/robot_lab-discovery
64
+ source_code_uri: https://github.com/MadBomber/robot_lab-discovery
65
+ changelog_uri: https://github.com/MadBomber/robot_lab-discovery/blob/main/CHANGELOG.md
66
+ rubygems_mfa_required: 'true'
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 3.2.0
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 4.0.17
82
+ specification_version: 4
83
+ summary: Zero-configuration mDNS/DNS-SD robot discovery for RobotLab on local networks.
84
+ test_files: []