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.
- checksums.yaml +7 -0
- data/.envrc +1 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +10 -0
- data/.quality/reek_baseline.txt +2 -0
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +33 -0
- data/CLAUDE.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/Rakefile +132 -0
- data/docs/api_reference.md +164 -0
- data/docs/getting_started.md +114 -0
- data/docs/how_it_works.md +131 -0
- data/docs/index.md +58 -0
- data/examples/01_basic_usage.rb +154 -0
- data/examples/common.rb +9 -0
- data/lib/robot_lab/discovery/advertiser.rb +45 -0
- data/lib/robot_lab/discovery/browser.rb +69 -0
- data/lib/robot_lab/discovery/constants.rb +7 -0
- data/lib/robot_lab/discovery/result.rb +9 -0
- data/lib/robot_lab/discovery/txt_record.rb +70 -0
- data/lib/robot_lab/discovery/version.rb +7 -0
- data/lib/robot_lab/discovery.rb +30 -0
- data/mkdocs.yml +117 -0
- data/sig/robot_lab/discovery.rbs +6 -0
- metadata +84 -0
|
@@ -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,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,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 © 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
|
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: []
|