Skip to content

networking

Networking module for RV communication.

Provides socket-based communication with RV for remote control and container loading functionality.

LoadContainerHandler

Handles loading containers from RV events.

Processes ayon_load_container events to load representations using appropriate loader plugins.

Source code in client/ayon_openrv/networking.py
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
class LoadContainerHandler:
    """Handles loading containers from RV events.

    Processes ayon_load_container events to load representations
    using appropriate loader plugins.
    """

    def __init__(self, event: Any) -> None:
        """Initialize the handler with an event.

        Args:
            event: The RV event to handle.

        Raises:
            ValueError: If event is not an ayon_load_container event.
        """
        if event.name() != "ayon_load_container":
            raise ValueError(
                f"LoadContainerHandler called on wrong event: {event}"
            )
        self.event = event

    def handle_event(self) -> None:
        """Handle the container loading event.

        Loads representations based on their file types using
        appropriate loader plugins.
        """
        event_data: dict = json.loads(self.event.contents())

        representation_ids = [
            entry["representation"]
            for entry in event_data
            if entry.get("representation")
        ]
        log.debug(f"representation_ids: {representation_ids}")

        # The sender knows which project the representations belong to, while
        # the running RV session may have a different project in context.
        # Prefer the project from the event and fall back to the current one.
        project_names = {
            entry["project_name"]
            for entry in event_data
            if entry.get("project_name")
        }
        if len(project_names) > 1:
            log.error(
                "Received representations of multiple projects in a single"
                f" event: {sorted(project_names)}"
            )
            return

        project_name = next(iter(project_names), None)
        if project_name is None:
            project_name = get_current_project_name()

        if project_name is None:
            log.error("No project name available")
            return

        repre_entities = get_representations(
            project_name=project_name, representation_ids=representation_ids
        )

        available_loaders = discover_loader_plugins(project_name)

        frames_loader_plugin = next(
            (
                loader
                for loader in available_loaders
                if loader.__name__ == "FramesLoader"
            ),
            None,
        )
        mov_loader_plugin = next(
            (
                loader
                for loader in available_loaders
                if loader.__name__ == "MovLoader"
            ),
            None,
        )

        if frames_loader_plugin is None:
            log.warning("FramesLoader plugin not found")
        if mov_loader_plugin is None:
            log.warning("MovLoader plugin not found")

        for repre in repre_entities:
            # Resolve with the project from the event. The one argument
            # 'get_representation_path' signature is deprecated and uses the
            # current project, which fails for representations coming from
            # another project than the running OpenRV session is in.
            try:
                filepath = get_representation_path(project_name, repre)
            except Exception:
                log.warning(
                    "Failed to resolve the path of representation"
                    f" '{repre['id']}' of project '{project_name}'.",
                    exc_info=True,
                )
                continue

            if not filepath:
                log.error(
                    "Representation"
                    f" '{repre['id']}' of project '{project_name}' has"
                    " no resolved path."
                )
                continue

            extension = os.path.splitext(filepath)[1].lstrip(".").lower()

            self._load_by_extension(
                repre,
                extension,
                project_name,
                frames_loader_plugin,
                mov_loader_plugin,
            )

    def _load_by_extension(
        self,
        repre: Any,
        extension: str,
        project_name: str,
        frames_loader: Any | None,
        mov_loader: Any | None,
    ) -> None:
        """Load a representation using the appropriate loader.

        Args:
            repre: The representation entity.
            extension: The file extension (without dot).
            project_name: The current project name.
            frames_loader: The frames loader plugin, if available.
            mov_loader: The mov loader plugin, if available.
        """
        # Check image extensions
        if frames_loader is not None:
            for ext in IMAGE_EXTENSIONS:
                ext = ext.lstrip(".")
                if ext == extension:
                    load_container(
                        frames_loader, repre, project_name=project_name
                    )
                    return

        # Check video extensions
        if mov_loader is not None:
            for ext in VIDEO_EXTENSIONS:
                ext = ext.lstrip(".")
                if ext == extension:
                    load_container(
                        mov_loader, repre, project_name=project_name
                    )
                    return

        log.warning(f"No loader found for extension: {extension}")

__init__(event)

Initialize the handler with an event.

Parameters:

Name Type Description Default
event Any

The RV event to handle.

required

Raises:

Type Description
ValueError

If event is not an ayon_load_container event.

Source code in client/ayon_openrv/networking.py
564
565
566
567
568
569
570
571
572
573
574
575
576
577
def __init__(self, event: Any) -> None:
    """Initialize the handler with an event.

    Args:
        event: The RV event to handle.

    Raises:
        ValueError: If event is not an ayon_load_container event.
    """
    if event.name() != "ayon_load_container":
        raise ValueError(
            f"LoadContainerHandler called on wrong event: {event}"
        )
    self.event = event

handle_event()

Handle the container loading event.

Loads representations based on their file types using appropriate loader plugins.

Source code in client/ayon_openrv/networking.py
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
def handle_event(self) -> None:
    """Handle the container loading event.

    Loads representations based on their file types using
    appropriate loader plugins.
    """
    event_data: dict = json.loads(self.event.contents())

    representation_ids = [
        entry["representation"]
        for entry in event_data
        if entry.get("representation")
    ]
    log.debug(f"representation_ids: {representation_ids}")

    # The sender knows which project the representations belong to, while
    # the running RV session may have a different project in context.
    # Prefer the project from the event and fall back to the current one.
    project_names = {
        entry["project_name"]
        for entry in event_data
        if entry.get("project_name")
    }
    if len(project_names) > 1:
        log.error(
            "Received representations of multiple projects in a single"
            f" event: {sorted(project_names)}"
        )
        return

    project_name = next(iter(project_names), None)
    if project_name is None:
        project_name = get_current_project_name()

    if project_name is None:
        log.error("No project name available")
        return

    repre_entities = get_representations(
        project_name=project_name, representation_ids=representation_ids
    )

    available_loaders = discover_loader_plugins(project_name)

    frames_loader_plugin = next(
        (
            loader
            for loader in available_loaders
            if loader.__name__ == "FramesLoader"
        ),
        None,
    )
    mov_loader_plugin = next(
        (
            loader
            for loader in available_loaders
            if loader.__name__ == "MovLoader"
        ),
        None,
    )

    if frames_loader_plugin is None:
        log.warning("FramesLoader plugin not found")
    if mov_loader_plugin is None:
        log.warning("MovLoader plugin not found")

    for repre in repre_entities:
        # Resolve with the project from the event. The one argument
        # 'get_representation_path' signature is deprecated and uses the
        # current project, which fails for representations coming from
        # another project than the running OpenRV session is in.
        try:
            filepath = get_representation_path(project_name, repre)
        except Exception:
            log.warning(
                "Failed to resolve the path of representation"
                f" '{repre['id']}' of project '{project_name}'.",
                exc_info=True,
            )
            continue

        if not filepath:
            log.error(
                "Representation"
                f" '{repre['id']}' of project '{project_name}' has"
                " no resolved path."
            )
            continue

        extension = os.path.splitext(filepath)[1].lstrip(".").lower()

        self._load_by_extension(
            repre,
            extension,
            project_name,
            frames_loader_plugin,
            mov_loader_plugin,
        )

RVConnector

Manages socket connection to RV for remote control.

This class provides a context manager interface for connecting to RV's network control interface, sending commands, and receiving responses.

Attributes:

Name Type Description
host

The hostname to connect to.

connection_name

The configured connection name.

name

The unique name this connection identifies itself with.

port

The port to connect to.

is_connected

Whether currently connected to RV.

Source code in client/ayon_openrv/networking.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
class RVConnector:
    """Manages socket connection to RV for remote control.

    This class provides a context manager interface for connecting
    to RV's network control interface, sending commands, and
    receiving responses.

    Attributes:
        host: The hostname to connect to.
        connection_name: The configured connection name.
        name: The unique name this connection identifies itself with.
        port: The port to connect to.
        is_connected: Whether currently connected to RV.
    """

    _cached_settings: dict[str, Any] | None = None

    def __init__(
        self,
        host: str | None = None,
        name: str | None = None,
        port: int | None = None,
    ) -> None:
        """Initialize RV connector.

        Args:
            host: Hostname to connect to. Defaults to "localhost".
            name: Connection name. Defaults to value from addon settings.
            port: Port number. Defaults to value from addon settings.
        """
        settings = self._get_settings()

        self.host = host or "localhost"
        self.connection_name = name or settings["network"]["conn_name"]
        self.name = get_unique_connection_name(self.connection_name)
        self.port = port or settings["network"]["conn_port"]

        self.is_connected = False
        self._sock: socket.socket | None = None

        self._attempts = 0
        self._elapsed = 0.0

        self.connect()

    @classmethod
    def _get_settings(cls) -> dict[str, Any]:
        """Get addon settings with lazy loading.

        Returns:
            The addon settings dictionary.
        """
        if cls._cached_settings is None:
            cls._cached_settings = get_addon_settings(
                OpenRVAddon.name, __version__
            )
        # At this point _cached_settings is guaranteed to be set
        return cls._cached_settings  # type: ignore[return-value]

    def __enter__(self) -> RVConnector:
        """Enter the context manager with retry logic.

        Attempts to connect with exponential backoff until success
        or timeout is reached.

        Returns:
            Self for context manager usage.

        Raises:
            ConnectionError: If connection times out.
        """
        settings = self._get_settings()
        start = time()
        self._attempts = 0
        timeout = float(settings["network"]["timeout"])

        while not self.is_connected:
            self._elapsed = time() - start
            if self._elapsed > timeout:
                raise ConnectionError(
                    f"Timeout after {self._elapsed:.1f}s connecting to RV. "
                    f"host={self.host}, port={self.port}, name={self.name}"
                )

            # Exponential backoff: 0.1s, 0.2s, 0.4s, ... max 2s
            if self._attempts > 0:
                delay = min(0.1 * (2**self._attempts), 2.0)
                log.debug(
                    f"Retry attempt {self._attempts}, waiting {delay:.2f}s"
                )
                sleep(delay)

            self._attempts += 1
            self.connect()

        return self

    def __exit__(self, *args: Any) -> None:
        """Exit the context manager and close connection."""
        self.close()

    @property
    def sock(self) -> socket.socket:
        """Get the current socket, creating one if needed.

        Returns:
            The socket instance.

        Raises:
            RuntimeError: If no socket is available.
        """
        if self._sock is None:
            raise RuntimeError("Socket not initialized")
        return self._sock

    @property
    def message_available(self) -> bool:
        """Check if a message is available on the socket.

        Returns:
            True if data is available to read, False otherwise.
        """
        if self._sock is None:
            return False

        try:
            msg = self._sock.recv(1, socket.MSG_PEEK)
            return len(msg) > 0
        except socket.timeout:
            return False
        except OSError as err:
            log.error(f"Error checking for message: {err}", exc_info=True)
            return False

    def connect(self) -> None:
        """Connect to the RV server.

        Creates a new socket and attempts connection. If already
        connected, does nothing.
        """
        if self.is_connected:
            return
        log.debug(
            f"Connecting with: host={self.host}, "
            f"port={self.port}, name={self.name} "
            f"attempt {self._attempts}"
        )
        self._connect_socket()

    def send_message(self, message: str) -> None:
        """Send a message to RV.

        Args:
            message: The message string to send.
        """
        log.debug(f"send_message: {message}")
        if not self.is_connected or self._sock is None:
            return

        msg = f"MESSAGE {len(message)} {message}"
        try:
            self._sock.sendall(msg.encode("utf-8"))
        except OSError:
            self.close()

    def send_event(
        self,
        event_name: str,
        event_contents: str,
        shall_return: bool = True,
    ) -> str:
        """Send a remote event and optionally wait for return value.

        Args:
            event_name: Event name from RV Reference Manual.
            event_contents: Event payload data.
            shall_return: Whether to wait for a return value.

        Returns:
            The return value string, or empty string if not waiting.
        """
        message = f"RETURNEVENT {event_name} * {event_contents}"
        self.send_message(message)
        if shall_return:
            return self._process_events(process_return_only=True)
        return ""

    def close(self) -> None:
        """Close the connection and clean up resources.

        The disconnect has to be orderly: RV reports a socket error on a
        previous controller connection as "Network Error ... Unable to write",
        so pending data is drained (and RV's keep-alive pings answered)
        before and after sending DISCONNECT, and the socket is half closed
        instead of reset.
        """
        if self.is_connected and self._sock is not None:
            try:
                self._drain_pending(0.1)
                self.send_message("DISCONNECT")
                timeout = int(
                    os.environ.get("AYON_RV_SOCKET_CLOSE_TIMEOUT", 100)
                )
                sleep(timeout / 1000)
                self._drain_pending(0.1)
            except OSError:
                pass  # Best effort disconnect message

        if self._sock is not None:
            try:
                # Half close: FIN on the write side, without discarding
                # whatever RV has already sent us.
                self._sock.shutdown(socket.SHUT_WR)
            except OSError:
                pass  # Already closed or not connected

            self._drain()

            try:
                self._sock.close()
            except OSError:
                pass

            self._sock = None

        self.is_connected = False

    def _drain_pending(self, timeout: float) -> None:
        """Read and answer whatever RV sent within the timeout."""
        if self._sock is None:
            return
        deadline = time() + timeout
        while True:
            remaining = deadline - time()
            if remaining <= 0:
                return
            try:
                self._sock.settimeout(min(remaining, 0.05))
                data = self._sock.recv(65536)
            except socket.timeout:
                continue
            except OSError:
                return
            if not data:
                return
            if b"PING " in data:
                try:
                    self._sock.sendall(b"PONG 1 p")
                except OSError:
                    return

    def _drain(self) -> None:
        """Read until RV closes its side or stops sending."""
        if self._sock is None:
            return
        try:
            self._sock.settimeout(0.25)
        except OSError:
            return
        while True:
            try:
                data = self._sock.recv(65536)
            except socket.timeout:
                return
            except OSError:
                return
            if not data:
                return

    def receive_message(self) -> tuple[str, str | None]:
        """Receive a message from the socket.

        Parses the RV protocol format: TYPE LENGTH DATA

        Returns:
            Tuple of (message_type, message_data). Data may be None
            on error.
        """
        msg_type = ""
        msg_data = None

        if self._sock is None:
            return (msg_type, msg_data)

        try:
            # Read message type until space
            while True:
                char = self._sock.recv(1).decode("utf-8")
                if char == " ":
                    break
                msg_type += char

            # Read message length until space
            length_str = ""
            while True:
                char = self._sock.recv(1).decode("utf-8")
                if char == " ":
                    break
                length_str += char

            # Read the actual message data
            msg_length = int(length_str)
            if msg_length > 0:
                data_bytes = b""
                while len(data_bytes) < msg_length:
                    chunk = self._sock.recv(msg_length - len(data_bytes))
                    if not chunk:
                        break
                    data_bytes += chunk
                msg_data = data_bytes.decode("utf-8")

        except (OSError, ValueError) as err:
            log.error(f"Error receiving message: {err}", exc_info=True)

        return (msg_type, msg_data)

    def _send_initial_greeting(self) -> None:
        """Send the initial greeting to establish connection."""
        if self._sock is None:
            return

        greeting = f"{self.name} rvController"
        cmd = f"NEWGREETING {len(greeting)} {greeting}"
        try:
            self._sock.sendall(cmd.encode("utf-8"))
        except OSError:
            self.is_connected = False

    def process_message(self, data: str | None) -> None:
        """Process a received message.

        Args:
            data: The message data to process.
        """
        log.debug(f"process message: data={data}")

    def _wait_for_message(self, timeout: float = 0.1) -> bool:
        """Wait for a message to become available.

        Args:
            timeout: Maximum time to wait in seconds.

        Returns:
            True if message is available, False otherwise.
        """
        start = time()
        while time() - start < timeout:
            if not self.is_connected:
                return False
            if self.message_available:
                return True
            sleep(0.01)
        return False

    def _process_events(self, process_return_only: bool = False) -> str:
        """Process incoming events from RV.

        Args:
            process_return_only: If True, only process RETURN events
                and return immediately upon receiving one.

        Returns:
            The return value for RETURN events, empty string otherwise.
        """
        while self.is_connected:
            if not self._wait_for_message(timeout=0.1):
                if not process_return_only:
                    break
                continue

            resp_type, resp_data = self.receive_message()
            log.debug(f"Received message: {resp_type}: {resp_data}")

            if resp_type == "MESSAGE":
                if resp_data == "DISCONNECT":
                    self.close()
                    return ""
                self.process_message(resp_data)

            elif resp_type == "PING":
                if self._sock is not None:
                    try:
                        self._sock.sendall(b"PONG 1 p")
                    except OSError:
                        pass

            elif resp_type == "RETURN" and process_return_only:
                return resp_data or ""

        return ""

    def _connect_socket(self) -> None:
        """Create and connect a new socket to RV.

        Creates a fresh socket for each connection attempt to avoid
        reuse issues with failed connections.
        """
        # Close existing socket if any
        if self._sock is not None:
            try:
                self._sock.close()
            except Exception:
                pass
            self._sock = None

        # Create fresh socket
        self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._sock.settimeout(5.0)

        try:
            self._sock.connect((self.host, self.port))
            self._send_initial_greeting()
            self._sock.sendall(b"PINGPONGCONTROL 1 0")
            self.is_connected = True
        except Exception as err:
            log.debug(f"Connection failed: {err}")
            self.is_connected = False
            # Clean up failed socket
            if self._sock is not None:
                try:
                    self._sock.close()
                except OSError:
                    pass
                self._sock = None
        else:
            log.info(
                f"Connected with: host={self.host}, "
                f"port={self.port}, name={self.name} "
                f"in {self._elapsed:.1f}sec. after {self._attempts} attempts."
            )

message_available property

Check if a message is available on the socket.

Returns:

Type Description
bool

True if data is available to read, False otherwise.

sock property

Get the current socket, creating one if needed.

Returns:

Type Description
socket

The socket instance.

Raises:

Type Description
RuntimeError

If no socket is available.

__enter__()

Enter the context manager with retry logic.

Attempts to connect with exponential backoff until success or timeout is reached.

Returns:

Type Description
RVConnector

Self for context manager usage.

Raises:

Type Description
ConnectionError

If connection times out.

Source code in client/ayon_openrv/networking.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def __enter__(self) -> RVConnector:
    """Enter the context manager with retry logic.

    Attempts to connect with exponential backoff until success
    or timeout is reached.

    Returns:
        Self for context manager usage.

    Raises:
        ConnectionError: If connection times out.
    """
    settings = self._get_settings()
    start = time()
    self._attempts = 0
    timeout = float(settings["network"]["timeout"])

    while not self.is_connected:
        self._elapsed = time() - start
        if self._elapsed > timeout:
            raise ConnectionError(
                f"Timeout after {self._elapsed:.1f}s connecting to RV. "
                f"host={self.host}, port={self.port}, name={self.name}"
            )

        # Exponential backoff: 0.1s, 0.2s, 0.4s, ... max 2s
        if self._attempts > 0:
            delay = min(0.1 * (2**self._attempts), 2.0)
            log.debug(
                f"Retry attempt {self._attempts}, waiting {delay:.2f}s"
            )
            sleep(delay)

        self._attempts += 1
        self.connect()

    return self

__exit__(*args)

Exit the context manager and close connection.

Source code in client/ayon_openrv/networking.py
222
223
224
def __exit__(self, *args: Any) -> None:
    """Exit the context manager and close connection."""
    self.close()

__init__(host=None, name=None, port=None)

Initialize RV connector.

Parameters:

Name Type Description Default
host str | None

Hostname to connect to. Defaults to "localhost".

None
name str | None

Connection name. Defaults to value from addon settings.

None
port int | None

Port number. Defaults to value from addon settings.

None
Source code in client/ayon_openrv/networking.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def __init__(
    self,
    host: str | None = None,
    name: str | None = None,
    port: int | None = None,
) -> None:
    """Initialize RV connector.

    Args:
        host: Hostname to connect to. Defaults to "localhost".
        name: Connection name. Defaults to value from addon settings.
        port: Port number. Defaults to value from addon settings.
    """
    settings = self._get_settings()

    self.host = host or "localhost"
    self.connection_name = name or settings["network"]["conn_name"]
    self.name = get_unique_connection_name(self.connection_name)
    self.port = port or settings["network"]["conn_port"]

    self.is_connected = False
    self._sock: socket.socket | None = None

    self._attempts = 0
    self._elapsed = 0.0

    self.connect()

close()

Close the connection and clean up resources.

The disconnect has to be orderly: RV reports a socket error on a previous controller connection as "Network Error ... Unable to write", so pending data is drained (and RV's keep-alive pings answered) before and after sending DISCONNECT, and the socket is half closed instead of reset.

Source code in client/ayon_openrv/networking.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def close(self) -> None:
    """Close the connection and clean up resources.

    The disconnect has to be orderly: RV reports a socket error on a
    previous controller connection as "Network Error ... Unable to write",
    so pending data is drained (and RV's keep-alive pings answered)
    before and after sending DISCONNECT, and the socket is half closed
    instead of reset.
    """
    if self.is_connected and self._sock is not None:
        try:
            self._drain_pending(0.1)
            self.send_message("DISCONNECT")
            timeout = int(
                os.environ.get("AYON_RV_SOCKET_CLOSE_TIMEOUT", 100)
            )
            sleep(timeout / 1000)
            self._drain_pending(0.1)
        except OSError:
            pass  # Best effort disconnect message

    if self._sock is not None:
        try:
            # Half close: FIN on the write side, without discarding
            # whatever RV has already sent us.
            self._sock.shutdown(socket.SHUT_WR)
        except OSError:
            pass  # Already closed or not connected

        self._drain()

        try:
            self._sock.close()
        except OSError:
            pass

        self._sock = None

    self.is_connected = False

connect()

Connect to the RV server.

Creates a new socket and attempts connection. If already connected, does nothing.

Source code in client/ayon_openrv/networking.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
def connect(self) -> None:
    """Connect to the RV server.

    Creates a new socket and attempts connection. If already
    connected, does nothing.
    """
    if self.is_connected:
        return
    log.debug(
        f"Connecting with: host={self.host}, "
        f"port={self.port}, name={self.name} "
        f"attempt {self._attempts}"
    )
    self._connect_socket()

process_message(data)

Process a received message.

Parameters:

Name Type Description Default
data str | None

The message data to process.

required
Source code in client/ayon_openrv/networking.py
453
454
455
456
457
458
459
def process_message(self, data: str | None) -> None:
    """Process a received message.

    Args:
        data: The message data to process.
    """
    log.debug(f"process message: data={data}")

receive_message()

Receive a message from the socket.

Parses the RV protocol format: TYPE LENGTH DATA

Returns:

Type Description
str

Tuple of (message_type, message_data). Data may be None

str | None

on error.

Source code in client/ayon_openrv/networking.py
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
def receive_message(self) -> tuple[str, str | None]:
    """Receive a message from the socket.

    Parses the RV protocol format: TYPE LENGTH DATA

    Returns:
        Tuple of (message_type, message_data). Data may be None
        on error.
    """
    msg_type = ""
    msg_data = None

    if self._sock is None:
        return (msg_type, msg_data)

    try:
        # Read message type until space
        while True:
            char = self._sock.recv(1).decode("utf-8")
            if char == " ":
                break
            msg_type += char

        # Read message length until space
        length_str = ""
        while True:
            char = self._sock.recv(1).decode("utf-8")
            if char == " ":
                break
            length_str += char

        # Read the actual message data
        msg_length = int(length_str)
        if msg_length > 0:
            data_bytes = b""
            while len(data_bytes) < msg_length:
                chunk = self._sock.recv(msg_length - len(data_bytes))
                if not chunk:
                    break
                data_bytes += chunk
            msg_data = data_bytes.decode("utf-8")

    except (OSError, ValueError) as err:
        log.error(f"Error receiving message: {err}", exc_info=True)

    return (msg_type, msg_data)

send_event(event_name, event_contents, shall_return=True)

Send a remote event and optionally wait for return value.

Parameters:

Name Type Description Default
event_name str

Event name from RV Reference Manual.

required
event_contents str

Event payload data.

required
shall_return bool

Whether to wait for a return value.

True

Returns:

Type Description
str

The return value string, or empty string if not waiting.

Source code in client/ayon_openrv/networking.py
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
def send_event(
    self,
    event_name: str,
    event_contents: str,
    shall_return: bool = True,
) -> str:
    """Send a remote event and optionally wait for return value.

    Args:
        event_name: Event name from RV Reference Manual.
        event_contents: Event payload data.
        shall_return: Whether to wait for a return value.

    Returns:
        The return value string, or empty string if not waiting.
    """
    message = f"RETURNEVENT {event_name} * {event_contents}"
    self.send_message(message)
    if shall_return:
        return self._process_events(process_return_only=True)
    return ""

send_message(message)

Send a message to RV.

Parameters:

Name Type Description Default
message str

The message string to send.

required
Source code in client/ayon_openrv/networking.py
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
def send_message(self, message: str) -> None:
    """Send a message to RV.

    Args:
        message: The message string to send.
    """
    log.debug(f"send_message: {message}")
    if not self.is_connected or self._sock is None:
        return

    msg = f"MESSAGE {len(message)} {message}"
    try:
        self._sock.sendall(msg.encode("utf-8"))
    except OSError:
        self.close()

find_available_network_port()

Return a currently unused local TCP port.

Source code in client/ayon_openrv/networking.py
73
74
75
76
77
def find_available_network_port() -> int:
    """Return a currently unused local TCP port."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.bind(("127.0.0.1", 0))
        return int(sock.getsockname()[1])

get_unique_connection_name(base_name)

Return a connection name unique to this process and connection.

RV keys the state of a network contact by "<name>@<host>" and rejects a new connection while a mapping for that contact is still present (RvNetworkDialog.cpp:630-637, "'' is already connected"). A rejected connection is reported to the user as an OpenRV network error, so every connection identifies itself with its own name.

Source code in client/ayon_openrv/networking.py
38
39
40
41
42
43
44
45
46
47
def get_unique_connection_name(base_name: str) -> str:
    """Return a connection name unique to this process and connection.

    RV keys the state of a network contact by ``"<name>@<host>"`` and rejects
    a new connection while a mapping for that contact is still present
    (RvNetworkDialog.cpp:630-637, "'<name>' is already connected"). A rejected
    connection is reported to the user as an OpenRV network error, so every
    connection identifies itself with its own name.
    """
    return f"{base_name}-{os.getpid()}-{next(_unique_connection_ids)}"

is_network_port_available(port)

Return True when nothing else is listening on the local port.

Source code in client/ayon_openrv/networking.py
50
51
52
53
54
55
56
57
def is_network_port_available(port: int) -> bool:
    """Return True when nothing else is listening on the local port."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        try:
            sock.bind(("127.0.0.1", port))
        except OSError:
            return False
    return True

is_network_port_listening(port, timeout=0.5)

Return True when something is listening on the local port.

'is_network_port_available' binds the port to find out whether it is free, which does not see a service listening on another address of the machine (e.g. OpenRV listening on 0.0.0.0 or on the LAN address). Connecting to the port sees those too.

Source code in client/ayon_openrv/networking.py
60
61
62
63
64
65
66
67
68
69
70
def is_network_port_listening(port: int, timeout: float = 0.5) -> bool:
    """Return True when something is listening on the local port.

    'is_network_port_available' binds the port to find out whether it is
    free, which does not see a service listening on another address of the
    machine (e.g. OpenRV listening on 0.0.0.0 or on the LAN address).
    Connecting to the port sees those too.
    """
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.settimeout(timeout)
        return sock.connect_ex(("127.0.0.1", port)) == 0

send_representation_to_existing_rv(project_name, representation, wait_for_connection=False, port=None)

Send representations to an RV instance over its network connection.

Parameters:

Name Type Description Default
project_name str

The name of the project.

required
representation dict

The representation dictionary to send.

required
wait_for_connection bool

When True the connection is retried until the addon settings timeout is reached instead of failing right away. Use this right after launching a new RV instance.

False
port int | None

Port to connect to. Defaults to the addon settings port. Pass the port the new RV instance was started with so the representation reaches that process instead of another session listening on the shared port.

None
Source code in client/ayon_openrv/networking.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def send_representation_to_existing_rv(
    project_name: str,
    representation: dict,
    wait_for_connection: bool = False,
    port: int | None = None,
) -> None:
    """Send representations to an RV instance over its network connection.

    Args:
        project_name: The name of the project.
        representation: The representation dictionary to send.
        wait_for_connection: When True the connection is retried until the
            addon settings timeout is reached instead of failing right away.
            Use this right after launching a new RV instance.
        port: Port to connect to. Defaults to the addon settings port. Pass
            the port the new RV instance was started with so the
            representation reaches that process instead of another session
            listening on the shared port.
    """
    rv_connector = RVConnector(port=port)
    if not rv_connector.is_connected and not wait_for_connection:
        raise LoadError(
            "No existing OpenRV connection found."
            " Make sure OpenRV is running and network connected."
        )

    payload = json.dumps([{
        "objectName": str(representation["name"]),
        "representation": str(representation["id"]),
        "project_name": str(project_name),
    }])
    try:
        # This also retries the connection
        with rv_connector:
            rv_connector.send_event(
                "ayon_load_container",
                payload,
                shall_return=False
            )
    except ConnectionError as exc:
        raise LoadError(
            "Could not connect to OpenRV. Make sure OpenRV is running"
            " and network connected."
        ) from exc