Skip to content

In Vantage InFusion installations with **multiple Master controllers* - Only objects from primary Master are imported #369

@sdhomecode

Description

@sdhomecode

Checklist

  • This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request).
  • This issue is not a duplicate feature request of previous feature requests.

Describe the feature you'd like

In Vantage InFusion installations with multiple Master controllers sharing one IP, aiovantage only returns objects from the primary master. Objects on the second master are not enumerated, even though they are reachable from the same IP and queryable from a single TCP connection.

My setup

  • 1 Vantage InFusion IP (192.168.1.98:2001)
  • 2 Master controllers (internal IDs 58 and 88), cross-linked
  • Total ~200 lights across both controllers in Design Center

Result with home-assistant-vantage / aiovantage 0.22.8

  • 93 lights imported (matches just one master's count)
  • 116 entities total, 161 devices in HA
  • Debug logs show OpenFilter queries but no per-master targeting

Result with homebridge-vantage

Same IP, same controllers — all ~200 lights imported, plus other objects from both masters.

Root cause

The HomeBridge plugin (source) prefixes every config query with <?Master N?> and iterates controller = 1, 2, 3, ... until a master fails to respond:

configuration.write("<?Master " + controller.toString() + "?><IConfiguration><OpenFilter>…")
// after each pass:
if (writeCount >= objecTypes.length) { controller++; writeCount = 0 }
// terminates when a master doesn't respond:
else shouldbreak = true

aiovantage's _config_client/connection.py has no <?Master N?> prefix — every query is sent untargeted. The InFusion routes those to the primary master only.

Reproduction / proof of concept

I wrote a standalone async Python client that queries the same InFusion using the master-prefixed protocol. Zero overlap between the two masters' VID sets and the total matches what HomeBridge sees:

Query Loads returned <Load Master="…"> attribute
<?Master 1?> 105 controller 58
<?Master 2?> 95 controller 88
Combined 200 both — zero VID overlap

Minimal working snippet that demonstrates the protocol — feel free to adapt:

import asyncio, re, xml.etree.ElementTree as ET

HOST, PORT = "192.168.1.98", 2001

async def fetch_loads(master: int) -> list[ET.Element]:
    reader, writer = await asyncio.open_connection(HOST, PORT)
    # 1) OpenFilter — note the <?Master N?> prefix
    writer.write(
        f"<?Master {master}?><IConfiguration><OpenFilter>"
        f"<call><Objects><ObjectType>Load</ObjectType></Objects></call>"
        f"</OpenFilter></IConfiguration>\n".encode()
    )
    await writer.drain()
    resp = (await reader.readuntil(b"</IConfiguration>\n")).decode()
    handle = re.search(r"<return>\s*(\d+)\s*</return>", resp).group(1)

    # 2) Page through results — also prefixed
    all_objs: list[ET.Element] = []
    while True:
        writer.write(
            f"<?Master {master}?><IConfiguration><GetFilterResults>"
            f"<call><hFilter>{handle}</hFilter><Count>100</Count>"
            f"<WholeObject>true</WholeObject></call><return/>"
            f"</GetFilterResults></IConfiguration>\n".encode()
        )
        await writer.drain()
        data = (await reader.readuntil(b"</IConfiguration>\n")).decode()
        data = re.sub(r"<\?Master\s+\d+\?>", "", data, count=1)
        page = ET.fromstring(data.strip()).findall(".//Object")
        if not page:
            break
        all_objs.extend(page)
        if len(page) < 100:
            break

    writer.write(
        f"<?Master {master}?><IConfiguration><CloseFilter>"
        f"<call>{handle}</call></CloseFilter></IConfiguration>\n".encode()
    )
    await writer.drain()
    writer.close()
    return all_objs

async def main():
    seen = set()
    for m in (1, 2, 3, 4):
        try:
            objs = await asyncio.wait_for(fetch_loads(m), timeout=10)
        except asyncio.TimeoutError:
            break
        new = {o.find("./Load").attrib["VID"] for o in objs if o.find("./Load") is not None} - seen
        if not new:
            break  # no master at that number / already covered
        seen |= new
        print(f"Master {m}: +{len(new)} new loads (total {len(seen)})")

asyncio.run(main())

Output on my system:

Master 1: +105 new loads (total 105)
Master 2: +95 new loads (total 200)

A control session (port 3001) also accepts the <?Master N?> prefix on commands like STATUS LOAD / LOAD <vid> <level> / GETLOAD <vid>, so live state and writes work across masters too — I have a custom HA integration doing this in production.

Proposed solution

Either:

  1. Auto-discovery: enumerate Master objects first, then re-issue each OpenFilter/GetFilterResults/CloseFilter once per master with the <?Master N?> prefix and merge the results.
  2. Configurable list of master IDs in Vantage() constructor (masters=[1, 2, 3]), defaulting to "all discovered" for backward compatibility.

Either way the controllers module would need a per-master pass; the command-client similarly needs the prefix when sending writes to a load that lives on a non-primary master.

Verification data available

Happy to share full debug logs (aiovantage: debug) or the PoC script above if useful.

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions