Skip to content

PiKVMWebSocket

PiKVMWebSocket

WebSocket client for PiKVM realtime events and HID input.

Usage:

async with kvm.ws() as ws:
    async for event in ws.events():
        print(event)
Source code in src/aiopikvm/_ws.py
 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
 555
 556
 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
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
class PiKVMWebSocket:
    """WebSocket client for PiKVM realtime events and HID input.

    Usage:

        async with kvm.ws() as ws:
            async for event in ws.events():
                print(event)
    """

    def __init__(
        self,
        url: str,
        *,
        user: str,
        passwd: str | Callable[[], str],
        auth: AuthMode = "headers",
        token: str | Callable[[], str] = "",
        verify_ssl: VerifyTypes = DEFAULT_VERIFY_SSL,
        cert: CertTypes | None = None,
        proxy: str | None = None,
        trust_env: bool = True,
        stream: bool = True,
        binary: bool = False,
        follow_redirects: bool = False,
        open_timeout: float = DEFAULT_TIMEOUT,
        close_timeout: float = DEFAULT_TIMEOUT,
        max_size: int | None = _WS_MAX_SIZE,
        max_queue: int = _WS_MAX_QUEUE,
        ping_interval: float | None = _WS_PING_INTERVAL,
        ping_timeout: float | None = _WS_PING_TIMEOUT,
    ) -> None:
        """Prepare a connection.

        Args:
            url: PiKVM base URL, ``https://`` or ``http://``.
            user: kvmd user name.
            passwd: Password, TOTP code appended if the device asks for one.
                A zero-argument callable is called when the handshake is
                made, so a rotating code is the one current then rather
                than the one current when this object was built.
            auth: Which credential the handshake carries. The upgrade request
                goes through the same chain a REST call does, so all three
                work; ``"cookie"`` needs *token* and ignores *user* and
                *passwd*.
            token: Session token for ``auth="cookie"``. A callable is
                called when the handshake is made, for the same reason
                *passwd* takes one: a session opened or refreshed after
                this object was built is the one that goes out.
            verify_ssl: What to trust; see
                [`VerifyTypes`][aiopikvm.VerifyTypes]. Off by default, the
                same as [`PiKVM`][aiopikvm.PiKVM]: a stock device serves a
                self-signed certificate.
            cert: Client certificate to present.
            proxy: Proxy URL to reach the device through. ``None``
                leaves it to the environment, unless *trust_env* says
                otherwise.
            trust_env: Read the proxy configuration from the
                environment. ``False`` connects directly.
            stream: Ask kvmd to treat this client as a video viewer. kvmd
                counts the sessions that did and runs the streamer while that
                count is above zero, so a client connected with ``False``
                lets the video pipeline stop under it — and
                ``StreamerResource.snapshot()`` then answers HTTP 503 unless
                something else is watching. Off only makes sense for a client
                that reads events and never looks at the picture.
            binary: Send input over kvmd's binary channel instead of as JSON
                events. Both reach the same handlers and the same validators;
                the binary frames are a few bytes each instead of a JSON
                object kvmd has to parse, which is why its own web UI uses
                them for every keystroke and mouse move. Off by default,
                since JSON is what this client has always sent and the
                encoding a packet capture can be read in. The binary channel
                was verified against kvmd 4.206.
            follow_redirects: Follow a redirected handshake instead of raising
                [`RedirectError`][aiopikvm.RedirectError]. Off by default: the
                upgrade carries the credential in a header — the password, or
                the session token under ``auth="cookie"`` — and following the
                redirect hands it to whatever the redirect points at.
            open_timeout: Seconds to wait for the handshake.
            close_timeout: Seconds to wait for the closing handshake.
            max_size: Largest frame to accept, in bytes, or ``None`` for no
                limit. kvmd's events are small; the cap is *websockets*' own.
            max_queue: How many frames the transport may buffer before it
                pauses reading. This connection reads continuously, so the
                queue is drained as fast as it fills and the setting is here
                for a caller who knows otherwise.
            ping_interval: Seconds between the protocol keepalive pings, or
                ``None`` to send none. This is *websockets*' own keepalive,
                not [`ping()`][aiopikvm.PiKVMWebSocket.ping]; turning it off
                means a link that dies silently is never noticed.
            ping_timeout: Seconds to wait for a keepalive pong before failing
                the connection, or ``None`` to wait forever.

        Raises:
            ConfigurationError: If the URL scheme is not ``https`` or ``http``.
        """
        # kvmd reads the flag with valid_bool, which takes 1/true/yes and
        # 0/false/no and answers 400 to anything else.
        self._url = f"{_ws_url(url)}/api/ws?stream={'1' if stream else '0'}"
        self._user = user
        self._passwd = passwd
        self._auth = auth
        self._token = token
        self._verify_ssl = verify_ssl
        self._cert = cert
        self._proxy = proxy
        self._trust_env = trust_env
        self._binary = binary
        self._follow_redirects = follow_redirects
        self._open_timeout = open_timeout
        self._close_timeout = close_timeout
        self._max_size = max_size
        self._max_queue = max_queue
        self._ping_interval = ping_interval
        self._ping_timeout = ping_timeout
        self._connection: websockets.asyncio.client.ClientConnection | None = None
        self._version: KvmdVersion | None = None
        # One task reads the socket and routes what it reads for everybody:
        # an event it buffered is still an event this connection received.
        # Nothing else touches `recv`, so the transport is never left unread.
        self._reader: asyncio.Task[None] | None = None
        self._wakeup = asyncio.Event()
        self._failure: WebSocketError | None = None
        self._reported = False
        self._pending: deque[dict[str, Any]] = deque()
        self._carry: dict[str, dict[str, Any]] = {}
        self._seen: dict[str, dict[str, Any]] = {}
        self._state = DeviceState()
        self._overflowed = False
        self._pong_waiters: list[asyncio.Future[float]] = []

    async def __aenter__(self) -> Self:
        """Open the connection and start reading it.

        A task begins draining the socket as soon as it is open, whether or
        not anything iterates [`events()`][aiopikvm.PiKVMWebSocket.events].
        That is not an optimisation: *websockets* parses frames in the
        transport callback and pauses reading once its inbound queue fills, so
        a socket nobody reads stops acknowledging the protocol keepalive and
        is dropped about forty seconds in — taking kvmd's streamer with it,
        since kvmd runs it for as long as a session says it wants video.

        Returns:
            This client, connected.

        Raises:
            ConfigurationError: Under ``auth="cookie"``, nothing has logged
                in, so there is no session token to send. The credential is
                read here rather than when the socket was built, so a session
                opened in between is the one that goes out — and one that
                never was is reported here. For a socket built by a
                [`PiKVM`][aiopikvm.PiKVM] client, so is that client having
                been closed, or never entered, since its cookie jar is where
                the token is read from. A login that came back without a
                token, kvmd running with authentication off, is not a session
                that never was: the handshake then carries no credential,
                which is what such a device accepts.
            AuthError: kvmd refused the credentials during the upgrade — 401
                when none reached it, 403 when the ones that did were
                rejected.
            RedirectError: The upgrade was redirected and *follow_redirects*
                is off. Following it would resend the credential to the target.
            APIError: kvmd rejected the upgrade for another reason, such as a
                query parameter its validators do not accept, or a proxy in
                front of it answered instead.
            WebSocketError: The connection could not be established: DNS,
                TLS, timeout, or a server that does not speak WebSocket.
        """
        self._connection = await _open(
            self._url,
            headers=self._credential_headers(),
            verify_ssl=self._verify_ssl,
            cert=self._cert,
            proxy=self._proxy,
            trust_env=self._trust_env,
            follow_redirects=self._follow_redirects,
            open_timeout=self._open_timeout,
            close_timeout=self._close_timeout,
            max_size=self._max_size,
            max_queue=self._max_queue,
            ping_interval=self._ping_interval,
            ping_timeout=self._ping_timeout,
        )

        self._version = None
        self._pending.clear()
        self._carry.clear()
        # The merge base belongs to a connection, not to a call: kvmd sends
        # each subsystem in full when the socket opens and only the changes
        # afterwards. A reconnection starts that over.
        self._seen.clear()
        self._state = DeviceState()
        self._overflowed = False
        self._failure = None
        self._reported = False
        self._wakeup.clear()
        self._start_reader()
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> None:
        """Close the connection, whatever happened inside the block.

        A [`ping()`][aiopikvm.PiKVMWebSocket.ping] still waiting for its
        answer fails here rather than waiting out its timeout: the socket it
        was waiting on is gone.

        A connection that broke while the block was doing something else is
        raised here, and this is the only place it can be: a block that holds
        the socket open without reading it has nowhere else to find out. It
        gives way to whatever the block itself raised, and says nothing when
        the failure has already reached the caller through
        [`events()`][aiopikvm.PiKVMWebSocket.events],
        [`states()`][aiopikvm.PiKVMWebSocket.states] or
        [`ping()`][aiopikvm.PiKVMWebSocket.ping].

        Args:
            exc_type: Type of the exception the block raised, if any.
            exc_val: The exception the block raised, if any.
            exc_tb: Traceback of that exception, if any.

        Raises:
            WebSocketError: The connection broke during the block and nothing
                in it noticed.
        """
        await self._stop_reader()
        if self._connection is not None:
            try:
                await self._connection.close()
            finally:
                self._connection = None
        if exc_type is None and self._failure is not None and not self._reported:
            self._reported = True
            raise self._failure

    def _start_reader(self) -> None:
        """Start the task that reads the socket, unless one is running."""
        if self._reader is None or self._reader.done():
            self._reader = asyncio.create_task(self._drain())

    async def _stop_reader(self) -> None:
        """Stop that task and fail anything still waiting on what it reads."""
        reader = self._reader
        self._reader = None
        if reader is not None:
            reader.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await reader
        self._fail_pongs("The connection closed before kvmd answered the ping")

    async def _drain(self) -> None:
        """Read the socket for as long as it has anything to say.

        Every frame is routed as it arrives — the version noted, a pong handed
        to whoever asked for one — and every event is kept for
        [`events()`][aiopikvm.PiKVMWebSocket.events]. Reading here rather than
        from `events()` is what keeps the transport drained; see
        [`__aenter__`][aiopikvm.PiKVMWebSocket.__aenter__] for why that
        matters.

        A clean close ends this quietly. Anything else is kept and handed to
        the next caller who asks, or raised by
        [`__aexit__`][aiopikvm.PiKVMWebSocket.__aexit__] if nobody does.
        """
        try:
            while True:
                event = await self._read_one()
                if event is not None:
                    self._buffer(event)
                self._wakeup.set()
        except _Finished:
            pass
        except Exception as exc:
            self._failure = (
                exc
                if isinstance(exc, WebSocketError)
                else WebSocketError(f"The socket reader stopped: {exc}")
            )
        finally:
            self._wakeup.set()
            # Nothing will answer a ping now, either way: a clean close is
            # still a close, and waiting out the timeout says nothing extra.
            self._fail_pongs(
                str(self._failure)
                if self._failure is not None
                else "The connection closed before kvmd answered the ping"
            )

    def _fail_pongs(self, message: str) -> None:
        """Fail every waiting [`ping()`][aiopikvm.PiKVMWebSocket.ping].

        Args:
            message: What to tell each of them. Each gets an exception of its
                own, so one caller's traceback is not another's.
        """
        for waiter in self._pong_waiters:
            if not waiter.done():
                waiter.set_exception(WebSocketError(message))
        self._pong_waiters.clear()

    def _raise_failure(self) -> None:
        """Hand the reader's failure to a caller, once.

        Raises:
            WebSocketError: The connection broke rather than closing cleanly.
        """
        if self._failure is not None:
            self._reported = True
            raise self._failure

    def _credential_headers(self) -> dict[str, str]:
        """Build the credential headers the upgrade request carries.

        Returns:
            The headers for this socket's auth mode.

        Raises:
            ConfigurationError: Under ``auth="cookie"``, nothing has logged
                in, so there is no session token to send. Only a socket built
                by [`PiKVM.ws()`][aiopikvm.PiKVM.ws] can say that: one built
                directly was handed whatever token it holds, and sends no
                credential header at all when that is empty.
        """
        return _credential_headers(self._auth, self._user, self._passwd, self._token)

    @property
    def version(self) -> KvmdVersion | None:
        """The kvmd version this connection reported, once it has been read.

        kvmd sends the ``loop`` event carrying it before anything else, and
        the socket is read from the moment it opens, so this fills in shortly
        after the connection is made whether or not anything is iterating
        [`events()`][aiopikvm.PiKVMWebSocket.events]. It is ``None`` until that
        first frame arrives, and on a connection whose ``loop`` event carried
        no usable version.
        """
        return self._version

    @property
    def state(self) -> DeviceState:
        """The device as [`states()`][aiopikvm.PiKVMWebSocket.states] left it.

        Every snapshot that call yields is this, so a loop that breaks out
        leaves the last one readable, and a caller that wants the picture
        rather than the changes can hold the socket open and read this
        whenever it suits — the socket is drained by a task of its own, so
        the events arrive either way.

        It is empty until something iterates
        [`states()`][aiopikvm.PiKVMWebSocket.states] — merely reading
        [`events()`][aiopikvm.PiKVMWebSocket.events] does not fill it in.
        Turning a payload into a model is where a kvmd this release does not
        describe correctly is found out, and that belongs to the call whose
        documented failure it is. A fresh connection empties it.
        """
        return self._state

    async def events(self) -> AsyncIterator[dict[str, Any]]:
        """Iterate over incoming events.

        Every event is a ``{"event_type": ..., "event": ...}`` object. The
        first one is always ``loop``, carrying the kvmd version; after it
        each subsystem sends its current state once, interleaved with the
        broadcasts other clients trigger, so nothing but ``loop`` arrives in
        a guaranteed order. See the WebSocket guide for the full list.

        Binary frames do not appear here. The only one kvmd sends is the
        answer to [`ping()`][aiopikvm.PiKVMWebSocket.ping], which that method
        consumes; anything else on that channel is logged and dropped, since a
        binary frame is an operation number and a payload, not an event.

        The iteration ends when either side closes the connection cleanly. A
        connection that breaks instead — the device rebooting, the network
        going away, kvmd restarting — raises, because a caller that only sees
        the loop finish cannot tell "kvmd has nothing more to say" from
        "the events stopped arriving".

        Nothing is read here: the socket is drained by a task of its own from
        the moment it opens, and this hands out what that task collected. A
        consumer slower than kvmd is broadcasting therefore falls behind in
        memory rather than on the wire — and once
        1024 events are waiting, the oldest are dropped, merged into the next
        event of their kind so that no field a
        [`states()`][aiopikvm.PiKVMWebSocket.states] snapshot rests on is lost.

        Yields:
            Parsed JSON event dictionaries.

        Raises:
            WebSocketError: The client is not connected, or the connection
                broke instead of closing cleanly.
        """
        self._ensure_connected()
        self._start_reader()
        while True:
            while self._pending:
                yield self._next_event()
            reader = self._reader
            if reader is None or reader.done():
                self._raise_failure()
                return
            # Cleared before the recheck, so an event buffered between the two
            # wakes this up rather than being waited past.
            self._wakeup.clear()
            if self._pending or reader.done():
                continue
            await self._wakeup.wait()

    def _next_event(self) -> dict[str, Any]:
        """Take the oldest buffered event, with anything dropped folded in.

        The subsystem's running total is kept up to date here rather than in
        [`states()`][aiopikvm.PiKVMWebSocket.states], because every event this
        connection hands out passes through here whichever way it was asked
        for. kvmd sends a subsystem in full when the socket opens and only the
        changes afterwards, so a `states()` that started merging from nothing
        — because `events()` had already taken the full one, or because an
        earlier `states()` loop was left — would be validating half a
        subsystem.

        Returns:
            The event, its payload merged over whatever was dropped from the
            same subsystem before it.
        """
        event = self._pending.popleft()
        event_type = event.get("event_type")
        if not isinstance(event_type, str):
            return event
        carried = self._carry.pop(event_type, None)
        payload = event.get("event")
        if carried is not None:
            payload = _merge(carried, payload) if isinstance(payload, dict) else carried
            event = {**event, "event": payload}
        if event_type in _STATE_MODELS and isinstance(payload, dict):
            self._seen[event_type] = _merge(self._seen.get(event_type, {}), payload)
        return event

    async def states(self) -> AsyncIterator[DeviceState]:
        """Iterate over the device state the events add up to.

        kvmd broadcasts a subsystem's state in pieces: the first event for
        each carries all of it, and every event after that carries only what
        changed — a ``streamer`` event with nothing but ``streamer`` in it, an
        ``info`` event with nothing but ``uptime``. Validating one of those on
        its own fails, because most of the model is simply not in it. This
        merges each event into what the same subsystem said before and hands
        back the whole picture, typed, once per event that changed something.

        Only the events that say something about the device produce a
        snapshot; ``loop`` and ``pong`` do not, and neither does an event type
        this release does not know. Nor does one it *does* know but cannot
        place on [`DeviceState`][aiopikvm.DeviceState] — the models and the
        fields are meant to agree and tests say they do, so that shows up as a
        subsystem which never arrives rather than as an exception. The kvmd
        version the ``loop`` event carries is on
        [`version`][aiopikvm.PiKVMWebSocket.version].

        Everything [`events()`][aiopikvm.PiKVMWebSocket.events] does about the
        connection applies here, and the two cannot be iterated over the same
        socket at once: this is [`events()`][aiopikvm.PiKVMWebSocket.events]
        with the states built on top.

        Yields:
            The device as of the event that has just arrived.

        What has been merged so far is on
        [`state`][aiopikvm.PiKVMWebSocket.state], so a loop that was left can
        be resumed and the last snapshot is readable from outside it.

        Raises:
            ResponseError: A merged payload did not match its model, which
                means a kvmd this release does not describe correctly. kvmd
                sends every subsystem in full when the socket opens, so a
                partial update always has something to merge into — whether it
                was this call that took the full one, an earlier `states()`,
                or [`events()`][aiopikvm.PiKVMWebSocket.events].
            WebSocketError: The client is not connected, or the connection
                broke instead of closing cleanly.
        """
        async for event in self.events():
            event_type = event.get("event_type")
            payload = event.get("event")
            if not isinstance(event_type, str) or not isinstance(payload, dict):
                continue
            if event_type == "clients":
                count = payload.get("count")
                if not isinstance(count, int):
                    continue
                self._state = dataclasses.replace(
                    self._state, updated=event_type, clients=count
                )
            elif event_type in _STATE_MODELS and event_type in _STATE_FIELDS:
                # The second test is for a table entry with nowhere to land,
                # which `replace()` answers with a bare `TypeError` at the
                # caller. Two tests keep the table and the fields together, so
                # this is for the build that shipped without them — skipped
                # like any other event this release cannot place (#143).
                #
                # Merged as the event came off the buffer, so this is that
                # subsystem's whole state and not only what just changed.
                self._state = dataclasses.replace(
                    self._state,
                    updated=event_type,
                    **{event_type: _as_state(event_type, self._seen[event_type])},
                )
            else:
                continue
            yield self._state

    async def ping(self, *, timeout: float = 10.0) -> float:
        """Ask kvmd for a pong, and wait for it.

        This is kvmd's application-level ping, not the protocol one: the
        request goes through the same event loop that dispatches HID input and
        broadcasts state, so the answer means that loop is running, not merely
        that something on the other end still holds a TCP socket open. Keeping
        the connection alive needs neither — *websockets* sends a protocol
        ping every 20 seconds by itself and drops the connection when one goes
        unanswered for another 20, which is what turns a silently dead link
        into a [`WebSocketError`][aiopikvm.WebSocketError]. That keepalive
        tells a dead link from a live one only because this connection is
        always being read: a pong is acknowledged where the frames are parsed,
        so a socket left unread fails its own keepalive.

        The answer arrives on the socket like everything else, so the task
        reading it is what hands the pong over, and the events it passes on
        the way are kept for the next
        [`events()`][aiopikvm.PiKVMWebSocket.events] call. The round trip is
        measured from the frame going out to the pong being read, which is not
        affected by how fast anything consumes
        [`events()`][aiopikvm.PiKVMWebSocket.events].

        Args:
            timeout: Seconds to wait for the answer.

        Returns:
            The round trip in seconds.

        Raises:
            WebSocketError: The client is not connected, the connection broke
                or closed before the answer arrived, or kvmd did not answer
                within *timeout*.
        """
        self._ensure_connected()
        self._start_reader()
        loop = asyncio.get_running_loop()
        waiter: asyncio.Future[float] = loop.create_future()
        self._pong_waiters.append(waiter)
        try:
            sent_at = loop.time()
            if self._binary:
                await self._send_bin(_OP_PING, b"", "ping")
            else:
                await self._send_event("ping", {})
            async with asyncio.timeout(timeout):
                answered_at = await waiter
            return answered_at - sent_at
        except TimeoutError as exc:
            raise WebSocketError(
                f"kvmd did not answer the ping within {timeout} s"
            ) from exc
        except WebSocketError:
            # The caller has been told the socket is gone, so __aexit__ has
            # nothing left to report.
            self._reported = True
            raise
        finally:
            if waiter in self._pong_waiters:
                self._pong_waiters.remove(waiter)

    def _buffer(self, event: dict[str, Any]) -> None:
        """Keep an event the reader took off the socket.

        The buffer is bounded, because the socket is read whether or not
        anything collects what it says: kvmd broadcasts its state regardless,
        and a caller may hold the connection open only to keep the streamer
        running.

        Dropping the oldest is not quite dropping it. kvmd sends each
        subsystem in full once and then only what changed, so an event lost
        from the front of the queue can leave every later one unusable —
        [`states()`][aiopikvm.PiKVMWebSocket.states] would have nothing to
        merge a partial update into. Its payload is therefore folded into a
        carry and merged back over the next event of the same type, which
        yields exactly what merging all of them in order would have.

        Args:
            event: The event to hand to the next
                [`events()`][aiopikvm.PiKVMWebSocket.events] call.
        """
        if len(self._pending) >= _PENDING_LIMIT:
            if not self._overflowed:
                logger.warning(
                    "Dropping WebSocket events: %d are buffered and nothing "
                    "is reading events()",
                    _PENDING_LIMIT,
                )
                self._overflowed = True
            self._carry_over(self._pending.popleft())
        self._pending.append(event)

    def _carry_over(self, dropped: dict[str, Any]) -> None:
        """Keep what a dropped event said, to merge into the next of its kind.

        Args:
            dropped: The event that did not fit in the buffer.
        """
        event_type = dropped.get("event_type")
        payload = dropped.get("event")
        if isinstance(event_type, str) and isinstance(payload, dict):
            self._carry[event_type] = _merge(self._carry.get(event_type, {}), payload)

    async def _read_one(self) -> dict[str, Any] | None:
        """Read one frame off the socket and route it.

        Returns:
            The event to hand to a caller, or ``None`` when the frame was
            consumed here — a pong, or something unusable.

        Raises:
            _Finished: The server closed the connection cleanly.
            WebSocketError: The client is not connected, or the connection
                broke instead of closing cleanly.
        """
        conn = self._ensure_connected()
        try:
            message = await conn.recv()
        except websockets.exceptions.ConnectionClosedOK as exc:
            raise _Finished from exc
        except websockets.exceptions.ConnectionClosed as exc:
            raise WebSocketError(
                f"Connection lost while reading events: {exc}"
            ) from exc
        except websockets.exceptions.WebSocketException as exc:
            raise WebSocketError(f"Failed to read from the socket: {exc}") from exc
        if isinstance(message, str):
            return self._route_text(message)
        self._route_binary(message)
        return None

    def _route_text(self, message: str) -> dict[str, Any] | None:
        """Parse a JSON frame and note what it says.

        Args:
            message: The text frame as it arrived.

        Returns:
            The parsed event, or ``None`` when it was not one.
        """
        try:
            event = json.loads(message)
        except json.JSONDecodeError as exc:
            logger.warning("Skipping malformed WebSocket message: %s", exc)
            return None
        if not isinstance(event, dict):
            logger.warning(
                "Skipping a WebSocket message that is not an event object: %s",
                type(event).__name__,
            )
            return None
        event_type = event.get("event_type")
        if event_type == "pong":
            self._resolve_pongs()
        elif event_type == "loop":
            self._note_version(event.get("event"))
        return event

    def _route_binary(self, data: bytes) -> None:
        """Handle a frame from kvmd's binary channel.

        Nothing comes back: no binary frame kvmd sends is an event. The only
        operation it has on this channel is the pong.

        Args:
            data: The binary frame, the operation number first.
        """
        if not data:
            logger.warning("Skipping an empty binary WebSocket frame")
        elif data[0] == _OP_PONG:
            self._resolve_pongs()
        else:
            logger.warning(
                "Skipping a binary WebSocket frame with unknown op %d", data[0]
            )

    def _resolve_pongs(self) -> None:
        """Hand the moment a pong arrived to everything waiting for one."""
        now = asyncio.get_running_loop().time()
        for waiter in self._pong_waiters:
            if not waiter.done():
                waiter.set_result(now)
        self._pong_waiters.clear()

    def _note_version(self, event: Any) -> None:
        """Remember the kvmd version from a ``loop`` event.

        Args:
            event: The event payload, whatever arrived in it.
        """
        version = event.get("version") if isinstance(event, dict) else None
        if not isinstance(version, dict):
            return
        major = version.get("major")
        minor = version.get("minor")
        if isinstance(major, int) and isinstance(minor, int):
            self._version = KvmdVersion(major, minor)

    def _ensure_connected(self) -> websockets.asyncio.client.ClientConnection:
        """Return the active connection or raise.

        Returns:
            The connection.

        Raises:
            WebSocketError: The client is not connected.
        """
        if self._connection is None:
            raise WebSocketError("Not connected")
        return self._connection

    async def _send_frame(self, frame: str | bytes, what: str) -> None:
        """Send one frame, whichever encoding it is in.

        Args:
            frame: The frame to send; text if it is a string, binary if not.
            what: Name of the event for the error message.

        Raises:
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        conn = self._ensure_connected()
        try:
            await conn.send(frame)
        except websockets.exceptions.WebSocketException as exc:
            # The caller has just been told the socket is gone, so whatever
            # the reader saw needs no second telling from __aexit__.
            self._reported = True
            raise WebSocketError(f"Failed to send {what!r}: {exc}") from exc

    async def _send_event(self, event_type: str, event: dict[str, Any]) -> None:
        """Send one JSON event frame.

        Args:
            event_type: kvmd event name.
            event: Payload for that event.

        Raises:
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        await self._send_frame(
            json.dumps({"event_type": event_type, "event": event}), event_type
        )

    async def _send_bin(self, op: int, payload: bytes, what: str) -> None:
        """Send one binary frame.

        Args:
            op: kvmd operation number, the first byte of the frame.
            payload: The rest of the frame, that operation's own encoding.
            what: Name of the event for the error message.

        Raises:
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        await self._send_frame(bytes([op]) + payload, what)

    async def send_key(self, key: str, *, state: bool, finish: bool = False) -> None:
        """Send a keyboard key event.

        Args:
            key: Key name, one of kvmd's web names such as ``"KeyA"`` or
                ``"ControlLeft"``; ``aiopikvm.resources.hid.KEY_NAMES`` holds
                every one of them. kvmd ignores an event it cannot map, and
                over this socket it does so without an answer of any kind —
                there is no 400 here to tell a typo from a keystroke that
                landed.
            state: ``True`` for press, ``False`` for release. kvmd holds the
                key until the release arrives.
            finish: Ask kvmd to release the key in the same event that
                pressed it, so a socket that goes away mid-keystroke leaves
                nothing held. It goes out only on a press, the only place
                kvmd acts on it; ``HIDResource.send_key`` and the HID guide
                have the keys it exempts.

        Raises:
            ConfigurationError: The key name cannot go into a binary frame,
                being empty, non-ASCII, or longer than 32 bytes. kvmd has no
                such name, and the frame would be dropped without a word.
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        # kvmd acts on the flag only on a press, so on a release it is dead
        # weight. Dropping it here keeps the frame to the two values kvmd
        # reads, rather than sending a bit it will ignore.
        finish = finish and state
        if self._binary:
            flags = (0b01 if state else 0) | (0b10 if finish else 0)
            await self._send_bin(
                _OP_KEY, bytes([flags]) + _name_bytes(key, "Key"), "key"
            )
        else:
            event: dict[str, Any] = {"key": key, "state": state}
            if finish:
                # kvmd defaults it to False, so leaving it out is the same
                # event a client that never heard of the flag would send.
                event["finish"] = True
            await self._send_event("key", event)

    async def send_mouse_move(self, to_x: int, to_y: int) -> None:
        """Move the mouse to an absolute position.

        The coordinates are not pixels. kvmd works in a resolution-independent
        space from -32768 (left, top) to 32767 (right, bottom), so ``0, 0`` is
        the middle of the screen and ``send_mouse_move(500, 300)`` lands a
        hair right of and below it — not 500 pixels from the corner. Convert
        from pixels with ``round(x / (width - 1) * 65535) - 32768``.

        Values outside the range are clamped, by kvmd for a JSON event and
        here for a binary one, which has nowhere to put them.

        Args:
            to_x: Horizontal position, -32768 to 32767.
            to_y: Vertical position, -32768 to 32767.

        Raises:
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        if self._binary:
            packed = struct.pack(
                ">hh",
                _clamp(to_x, _MOVE_MIN, _MOVE_MAX),
                _clamp(to_y, _MOVE_MIN, _MOVE_MAX),
            )
            await self._send_bin(_OP_MOUSE_MOVE, packed, "mouse_move")
        else:
            await self._send_event("mouse_move", {"to": {"x": to_x, "y": to_y}})

    async def send_mouse_button(self, button: MouseButton, state: bool) -> None:
        """Send a mouse button event.

        Args:
            button: Button name, one of
                ``aiopikvm.resources.hid.MouseButton``. A name kvmd does not
                know is dropped inside its handler with no answer of any
                kind, the way a bad key name is — there is no 400 on this
                socket to tell a typo from a click that landed.
            state: ``True`` for press, ``False`` for release.

        Raises:
            ConfigurationError: The button name cannot go into a binary frame,
                being empty, non-ASCII, or longer than 32 bytes. kvmd has no
                such name, and the frame would be dropped without a word.
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        if self._binary:
            flags = 0b01 if state else 0
            await self._send_bin(
                _OP_MOUSE_BUTTON,
                bytes([flags]) + _name_bytes(button, "Mouse button"),
                "mouse_button",
            )
        else:
            await self._send_event("mouse_button", {"button": button, "state": state})

    async def send_mouse_wheel(self, delta_x: int, delta_y: int) -> None:
        """Send a mouse wheel event.

        Deltas are steps in kvmd's own range, -127 to 127, clamped rather
        than rejected — by kvmd for a JSON event and here for a binary one,
        which has nowhere to put a larger number — and carried in the HID
        wheel field. They are not the browser's pixel deltas: a browser
        reports a scroll-down gesture as a positive ``deltaY``, and kvmd's own
        web UI negates it and sizes it by its scroll-rate setting (1 to 25, 5
        by default), so the gesture reaches the device as ``delta_y = -5``.

        Args:
            delta_x: Horizontal step, -127 to 127. It needs a backend with a
                horizontal wheel behind it, and in kvmd 4.206 only ``otg`` has
                one, while its ``horizontal_wheel`` option is on — the
                default. ``serial``, ``spi``, ``ch9329`` and ``bt`` drop it
                without a word, and which way a positive step pans is not
                settled here.
            delta_y: Vertical step, -127 to 127. Negative scrolls down on a
                host with the usual wheel mapping. ``ch9329`` keeps only the
                sign and sends one detent, a zero counting as negative, so the
                size is lost there.

        Raises:
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        await self._send_delta(_OP_MOUSE_WHEEL, "mouse_wheel", delta_x, delta_y)

    async def send_mouse_wheel_batch(
        self, deltas: Iterable[tuple[int, int]], *, squash: bool = False
    ) -> None:
        """Send several wheel steps in one frame.

        A step means what it does in ``send_mouse_wheel()``, backends and
        directions included.

        Args:
            deltas: ``(delta_x, delta_y)`` steps, in the order they happened.
                An empty batch is a frame kvmd does nothing with.
            squash: Ask kvmd to add the steps together instead of reporting
                each one. See
                [`send_mouse_relative_batch()`][aiopikvm.PiKVMWebSocket.send_mouse_relative_batch],
                which squashes by the same rule.

        Raises:
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        await self._send_deltas(_OP_MOUSE_WHEEL, "mouse_wheel", deltas, squash=squash)

    async def send_mouse_relative(self, delta_x: int, delta_y: int) -> None:
        """Move the mouse by an amount, rather than to a position.

        This needs the mouse in a relative mode: kvmd drops a relative event
        while the current mouse is absolute, and drops
        [`send_mouse_move()`][aiopikvm.PiKVMWebSocket.send_mouse_move] while
        it is relative — in both cases without a word to the sender.
        ``kvm.hid.set_params(mouse_output="usb_rel")`` switches it, and
        ``HIDState.mouse.absolute`` says which mode is on.

        Deltas are steps in kvmd's own range, -127 to 127, clamped rather than
        rejected — by kvmd for a JSON event and here for a binary one, which
        has nowhere to put a larger number. A gesture longer than one step
        therefore takes several events, which is what
        [`send_mouse_relative_batch()`][aiopikvm.PiKVMWebSocket.send_mouse_relative_batch]
        is for.

        Args:
            delta_x: Horizontal step, -127 to 127. Positive moves right.
            delta_y: Vertical step, -127 to 127. Positive moves down.

        Raises:
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        await self._send_delta(_OP_MOUSE_RELATIVE, "mouse_relative", delta_x, delta_y)

    async def send_mouse_relative_batch(
        self, deltas: Iterable[tuple[int, int]], *, squash: bool = False
    ) -> None:
        """Send several relative steps in one frame.

        One frame for a burst of movement is what kvmd's own web UI does: it
        collects the deltas a mouse produced between two screen refreshes and
        sends them together, rather than a frame per browser event.

        With *squash*, kvmd adds consecutive steps up instead of reporting
        each one, and starts a new sum whenever the running total would leave
        the -127 to 127 a HID report can carry. Fewer reports reach the host
        that way, at the cost of the shape of the path between them — and a
        batch that adds up to nothing sends nothing at all, since kvmd drops a
        final sum of ``(0, 0)``.

        Args:
            deltas: ``(delta_x, delta_y)`` steps, in the order they happened.
                An empty batch is a frame kvmd does nothing with.
            squash: Add the steps together where they fit into one report.

        Raises:
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        await self._send_deltas(
            _OP_MOUSE_RELATIVE, "mouse_relative", deltas, squash=squash
        )

    async def _send_delta(
        self, op: int, event_type: str, delta_x: int, delta_y: int
    ) -> None:
        """Send one step of a delta event.

        A single step keeps the shape kvmd's own web UI sends for one: a
        ``delta`` object rather than a list of one, and no squash flag, which
        means nothing for a step that has nothing to be added to.

        Args:
            op: kvmd operation number for the binary encoding.
            event_type: kvmd event name for the JSON encoding.
            delta_x: Horizontal step.
            delta_y: Vertical step.

        Raises:
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        if self._binary:
            await self._send_bin(
                op, b"\x00" + _pack_delta(delta_x, delta_y), event_type
            )
        else:
            await self._send_event(event_type, {"delta": {"x": delta_x, "y": delta_y}})

    async def _send_deltas(
        self,
        op: int,
        event_type: str,
        deltas: Iterable[tuple[int, int]],
        *,
        squash: bool,
    ) -> None:
        """Send a batch of steps of a delta event.

        Args:
            op: kvmd operation number for the binary encoding.
            event_type: kvmd event name for the JSON encoding.
            deltas: The steps, in the order they happened.
            squash: Ask kvmd to add them together where they fit one report.

        Raises:
            WebSocketError: The client is not connected, or the connection
                broke before the frame could be sent.
        """
        steps = list(deltas)
        if self._binary:
            payload = bytes([0b01 if squash else 0]) + b"".join(
                _pack_delta(delta_x, delta_y) for (delta_x, delta_y) in steps
            )
            await self._send_bin(op, payload, event_type)
        else:
            await self._send_event(
                event_type,
                {
                    "delta": [
                        {"x": delta_x, "y": delta_y} for (delta_x, delta_y) in steps
                    ],
                    "squash": squash,
                },
            )

version property

The kvmd version this connection reported, once it has been read.

kvmd sends the loop event carrying it before anything else, and the socket is read from the moment it opens, so this fills in shortly after the connection is made whether or not anything is iterating events(). It is None until that first frame arrives, and on a connection whose loop event carried no usable version.

state property

The device as states() left it.

Every snapshot that call yields is this, so a loop that breaks out leaves the last one readable, and a caller that wants the picture rather than the changes can hold the socket open and read this whenever it suits — the socket is drained by a task of its own, so the events arrive either way.

It is empty until something iterates states() — merely reading events() does not fill it in. Turning a payload into a model is where a kvmd this release does not describe correctly is found out, and that belongs to the call whose documented failure it is. A fresh connection empties it.

__init__(url, *, user, passwd, auth='headers', token='', verify_ssl=DEFAULT_VERIFY_SSL, cert=None, proxy=None, trust_env=True, stream=True, binary=False, follow_redirects=False, open_timeout=DEFAULT_TIMEOUT, close_timeout=DEFAULT_TIMEOUT, max_size=_WS_MAX_SIZE, max_queue=_WS_MAX_QUEUE, ping_interval=_WS_PING_INTERVAL, ping_timeout=_WS_PING_TIMEOUT)

Prepare a connection.

Parameters:

Name Type Description Default
url str

PiKVM base URL, https:// or http://.

required
user str

kvmd user name.

required
passwd str | Callable[[], str]

Password, TOTP code appended if the device asks for one. A zero-argument callable is called when the handshake is made, so a rotating code is the one current then rather than the one current when this object was built.

required
auth AuthMode

Which credential the handshake carries. The upgrade request goes through the same chain a REST call does, so all three work; "cookie" needs token and ignores user and passwd.

'headers'
token str | Callable[[], str]

Session token for auth="cookie". A callable is called when the handshake is made, for the same reason passwd takes one: a session opened or refreshed after this object was built is the one that goes out.

''
verify_ssl VerifyTypes

What to trust; see VerifyTypes. Off by default, the same as PiKVM: a stock device serves a self-signed certificate.

DEFAULT_VERIFY_SSL
cert CertTypes | None

Client certificate to present.

None
proxy str | None

Proxy URL to reach the device through. None leaves it to the environment, unless trust_env says otherwise.

None
trust_env bool

Read the proxy configuration from the environment. False connects directly.

True
stream bool

Ask kvmd to treat this client as a video viewer. kvmd counts the sessions that did and runs the streamer while that count is above zero, so a client connected with False lets the video pipeline stop under it — and StreamerResource.snapshot() then answers HTTP 503 unless something else is watching. Off only makes sense for a client that reads events and never looks at the picture.

True
binary bool

Send input over kvmd's binary channel instead of as JSON events. Both reach the same handlers and the same validators; the binary frames are a few bytes each instead of a JSON object kvmd has to parse, which is why its own web UI uses them for every keystroke and mouse move. Off by default, since JSON is what this client has always sent and the encoding a packet capture can be read in. The binary channel was verified against kvmd 4.206.

False
follow_redirects bool

Follow a redirected handshake instead of raising RedirectError. Off by default: the upgrade carries the credential in a header — the password, or the session token under auth="cookie" — and following the redirect hands it to whatever the redirect points at.

False
open_timeout float

Seconds to wait for the handshake.

DEFAULT_TIMEOUT
close_timeout float

Seconds to wait for the closing handshake.

DEFAULT_TIMEOUT
max_size int | None

Largest frame to accept, in bytes, or None for no limit. kvmd's events are small; the cap is websockets' own.

_WS_MAX_SIZE
max_queue int

How many frames the transport may buffer before it pauses reading. This connection reads continuously, so the queue is drained as fast as it fills and the setting is here for a caller who knows otherwise.

_WS_MAX_QUEUE
ping_interval float | None

Seconds between the protocol keepalive pings, or None to send none. This is websockets' own keepalive, not ping(); turning it off means a link that dies silently is never noticed.

_WS_PING_INTERVAL
ping_timeout float | None

Seconds to wait for a keepalive pong before failing the connection, or None to wait forever.

_WS_PING_TIMEOUT

Raises:

Type Description
ConfigurationError

If the URL scheme is not https or http.

Source code in src/aiopikvm/_ws.py
def __init__(
    self,
    url: str,
    *,
    user: str,
    passwd: str | Callable[[], str],
    auth: AuthMode = "headers",
    token: str | Callable[[], str] = "",
    verify_ssl: VerifyTypes = DEFAULT_VERIFY_SSL,
    cert: CertTypes | None = None,
    proxy: str | None = None,
    trust_env: bool = True,
    stream: bool = True,
    binary: bool = False,
    follow_redirects: bool = False,
    open_timeout: float = DEFAULT_TIMEOUT,
    close_timeout: float = DEFAULT_TIMEOUT,
    max_size: int | None = _WS_MAX_SIZE,
    max_queue: int = _WS_MAX_QUEUE,
    ping_interval: float | None = _WS_PING_INTERVAL,
    ping_timeout: float | None = _WS_PING_TIMEOUT,
) -> None:
    """Prepare a connection.

    Args:
        url: PiKVM base URL, ``https://`` or ``http://``.
        user: kvmd user name.
        passwd: Password, TOTP code appended if the device asks for one.
            A zero-argument callable is called when the handshake is
            made, so a rotating code is the one current then rather
            than the one current when this object was built.
        auth: Which credential the handshake carries. The upgrade request
            goes through the same chain a REST call does, so all three
            work; ``"cookie"`` needs *token* and ignores *user* and
            *passwd*.
        token: Session token for ``auth="cookie"``. A callable is
            called when the handshake is made, for the same reason
            *passwd* takes one: a session opened or refreshed after
            this object was built is the one that goes out.
        verify_ssl: What to trust; see
            [`VerifyTypes`][aiopikvm.VerifyTypes]. Off by default, the
            same as [`PiKVM`][aiopikvm.PiKVM]: a stock device serves a
            self-signed certificate.
        cert: Client certificate to present.
        proxy: Proxy URL to reach the device through. ``None``
            leaves it to the environment, unless *trust_env* says
            otherwise.
        trust_env: Read the proxy configuration from the
            environment. ``False`` connects directly.
        stream: Ask kvmd to treat this client as a video viewer. kvmd
            counts the sessions that did and runs the streamer while that
            count is above zero, so a client connected with ``False``
            lets the video pipeline stop under it — and
            ``StreamerResource.snapshot()`` then answers HTTP 503 unless
            something else is watching. Off only makes sense for a client
            that reads events and never looks at the picture.
        binary: Send input over kvmd's binary channel instead of as JSON
            events. Both reach the same handlers and the same validators;
            the binary frames are a few bytes each instead of a JSON
            object kvmd has to parse, which is why its own web UI uses
            them for every keystroke and mouse move. Off by default,
            since JSON is what this client has always sent and the
            encoding a packet capture can be read in. The binary channel
            was verified against kvmd 4.206.
        follow_redirects: Follow a redirected handshake instead of raising
            [`RedirectError`][aiopikvm.RedirectError]. Off by default: the
            upgrade carries the credential in a header — the password, or
            the session token under ``auth="cookie"`` — and following the
            redirect hands it to whatever the redirect points at.
        open_timeout: Seconds to wait for the handshake.
        close_timeout: Seconds to wait for the closing handshake.
        max_size: Largest frame to accept, in bytes, or ``None`` for no
            limit. kvmd's events are small; the cap is *websockets*' own.
        max_queue: How many frames the transport may buffer before it
            pauses reading. This connection reads continuously, so the
            queue is drained as fast as it fills and the setting is here
            for a caller who knows otherwise.
        ping_interval: Seconds between the protocol keepalive pings, or
            ``None`` to send none. This is *websockets*' own keepalive,
            not [`ping()`][aiopikvm.PiKVMWebSocket.ping]; turning it off
            means a link that dies silently is never noticed.
        ping_timeout: Seconds to wait for a keepalive pong before failing
            the connection, or ``None`` to wait forever.

    Raises:
        ConfigurationError: If the URL scheme is not ``https`` or ``http``.
    """
    # kvmd reads the flag with valid_bool, which takes 1/true/yes and
    # 0/false/no and answers 400 to anything else.
    self._url = f"{_ws_url(url)}/api/ws?stream={'1' if stream else '0'}"
    self._user = user
    self._passwd = passwd
    self._auth = auth
    self._token = token
    self._verify_ssl = verify_ssl
    self._cert = cert
    self._proxy = proxy
    self._trust_env = trust_env
    self._binary = binary
    self._follow_redirects = follow_redirects
    self._open_timeout = open_timeout
    self._close_timeout = close_timeout
    self._max_size = max_size
    self._max_queue = max_queue
    self._ping_interval = ping_interval
    self._ping_timeout = ping_timeout
    self._connection: websockets.asyncio.client.ClientConnection | None = None
    self._version: KvmdVersion | None = None
    # One task reads the socket and routes what it reads for everybody:
    # an event it buffered is still an event this connection received.
    # Nothing else touches `recv`, so the transport is never left unread.
    self._reader: asyncio.Task[None] | None = None
    self._wakeup = asyncio.Event()
    self._failure: WebSocketError | None = None
    self._reported = False
    self._pending: deque[dict[str, Any]] = deque()
    self._carry: dict[str, dict[str, Any]] = {}
    self._seen: dict[str, dict[str, Any]] = {}
    self._state = DeviceState()
    self._overflowed = False
    self._pong_waiters: list[asyncio.Future[float]] = []

__aenter__() async

Open the connection and start reading it.

A task begins draining the socket as soon as it is open, whether or not anything iterates events(). That is not an optimisation: websockets parses frames in the transport callback and pauses reading once its inbound queue fills, so a socket nobody reads stops acknowledging the protocol keepalive and is dropped about forty seconds in — taking kvmd's streamer with it, since kvmd runs it for as long as a session says it wants video.

Returns:

Type Description
Self

This client, connected.

Raises:

Type Description
ConfigurationError

Under auth="cookie", nothing has logged in, so there is no session token to send. The credential is read here rather than when the socket was built, so a session opened in between is the one that goes out — and one that never was is reported here. For a socket built by a PiKVM client, so is that client having been closed, or never entered, since its cookie jar is where the token is read from. A login that came back without a token, kvmd running with authentication off, is not a session that never was: the handshake then carries no credential, which is what such a device accepts.

AuthError

kvmd refused the credentials during the upgrade — 401 when none reached it, 403 when the ones that did were rejected.

RedirectError

The upgrade was redirected and follow_redirects is off. Following it would resend the credential to the target.

APIError

kvmd rejected the upgrade for another reason, such as a query parameter its validators do not accept, or a proxy in front of it answered instead.

WebSocketError

The connection could not be established: DNS, TLS, timeout, or a server that does not speak WebSocket.

Source code in src/aiopikvm/_ws.py
async def __aenter__(self) -> Self:
    """Open the connection and start reading it.

    A task begins draining the socket as soon as it is open, whether or
    not anything iterates [`events()`][aiopikvm.PiKVMWebSocket.events].
    That is not an optimisation: *websockets* parses frames in the
    transport callback and pauses reading once its inbound queue fills, so
    a socket nobody reads stops acknowledging the protocol keepalive and
    is dropped about forty seconds in — taking kvmd's streamer with it,
    since kvmd runs it for as long as a session says it wants video.

    Returns:
        This client, connected.

    Raises:
        ConfigurationError: Under ``auth="cookie"``, nothing has logged
            in, so there is no session token to send. The credential is
            read here rather than when the socket was built, so a session
            opened in between is the one that goes out — and one that
            never was is reported here. For a socket built by a
            [`PiKVM`][aiopikvm.PiKVM] client, so is that client having
            been closed, or never entered, since its cookie jar is where
            the token is read from. A login that came back without a
            token, kvmd running with authentication off, is not a session
            that never was: the handshake then carries no credential,
            which is what such a device accepts.
        AuthError: kvmd refused the credentials during the upgrade — 401
            when none reached it, 403 when the ones that did were
            rejected.
        RedirectError: The upgrade was redirected and *follow_redirects*
            is off. Following it would resend the credential to the target.
        APIError: kvmd rejected the upgrade for another reason, such as a
            query parameter its validators do not accept, or a proxy in
            front of it answered instead.
        WebSocketError: The connection could not be established: DNS,
            TLS, timeout, or a server that does not speak WebSocket.
    """
    self._connection = await _open(
        self._url,
        headers=self._credential_headers(),
        verify_ssl=self._verify_ssl,
        cert=self._cert,
        proxy=self._proxy,
        trust_env=self._trust_env,
        follow_redirects=self._follow_redirects,
        open_timeout=self._open_timeout,
        close_timeout=self._close_timeout,
        max_size=self._max_size,
        max_queue=self._max_queue,
        ping_interval=self._ping_interval,
        ping_timeout=self._ping_timeout,
    )

    self._version = None
    self._pending.clear()
    self._carry.clear()
    # The merge base belongs to a connection, not to a call: kvmd sends
    # each subsystem in full when the socket opens and only the changes
    # afterwards. A reconnection starts that over.
    self._seen.clear()
    self._state = DeviceState()
    self._overflowed = False
    self._failure = None
    self._reported = False
    self._wakeup.clear()
    self._start_reader()
    return self

__aexit__(exc_type, exc_val, exc_tb) async

Close the connection, whatever happened inside the block.

A ping() still waiting for its answer fails here rather than waiting out its timeout: the socket it was waiting on is gone.

A connection that broke while the block was doing something else is raised here, and this is the only place it can be: a block that holds the socket open without reading it has nowhere else to find out. It gives way to whatever the block itself raised, and says nothing when the failure has already reached the caller through events(), states() or ping().

Parameters:

Name Type Description Default
exc_type type[BaseException] | None

Type of the exception the block raised, if any.

required
exc_val BaseException | None

The exception the block raised, if any.

required
exc_tb TracebackType | None

Traceback of that exception, if any.

required

Raises:

Type Description
WebSocketError

The connection broke during the block and nothing in it noticed.

Source code in src/aiopikvm/_ws.py
async def __aexit__(
    self,
    exc_type: type[BaseException] | None,
    exc_val: BaseException | None,
    exc_tb: TracebackType | None,
) -> None:
    """Close the connection, whatever happened inside the block.

    A [`ping()`][aiopikvm.PiKVMWebSocket.ping] still waiting for its
    answer fails here rather than waiting out its timeout: the socket it
    was waiting on is gone.

    A connection that broke while the block was doing something else is
    raised here, and this is the only place it can be: a block that holds
    the socket open without reading it has nowhere else to find out. It
    gives way to whatever the block itself raised, and says nothing when
    the failure has already reached the caller through
    [`events()`][aiopikvm.PiKVMWebSocket.events],
    [`states()`][aiopikvm.PiKVMWebSocket.states] or
    [`ping()`][aiopikvm.PiKVMWebSocket.ping].

    Args:
        exc_type: Type of the exception the block raised, if any.
        exc_val: The exception the block raised, if any.
        exc_tb: Traceback of that exception, if any.

    Raises:
        WebSocketError: The connection broke during the block and nothing
            in it noticed.
    """
    await self._stop_reader()
    if self._connection is not None:
        try:
            await self._connection.close()
        finally:
            self._connection = None
    if exc_type is None and self._failure is not None and not self._reported:
        self._reported = True
        raise self._failure

events() async

Iterate over incoming events.

Every event is a {"event_type": ..., "event": ...} object. The first one is always loop, carrying the kvmd version; after it each subsystem sends its current state once, interleaved with the broadcasts other clients trigger, so nothing but loop arrives in a guaranteed order. See the WebSocket guide for the full list.

Binary frames do not appear here. The only one kvmd sends is the answer to ping(), which that method consumes; anything else on that channel is logged and dropped, since a binary frame is an operation number and a payload, not an event.

The iteration ends when either side closes the connection cleanly. A connection that breaks instead — the device rebooting, the network going away, kvmd restarting — raises, because a caller that only sees the loop finish cannot tell "kvmd has nothing more to say" from "the events stopped arriving".

Nothing is read here: the socket is drained by a task of its own from the moment it opens, and this hands out what that task collected. A consumer slower than kvmd is broadcasting therefore falls behind in memory rather than on the wire — and once 1024 events are waiting, the oldest are dropped, merged into the next event of their kind so that no field a states() snapshot rests on is lost.

Yields:

Type Description
AsyncIterator[dict[str, Any]]

Parsed JSON event dictionaries.

Raises:

Type Description
WebSocketError

The client is not connected, or the connection broke instead of closing cleanly.

Source code in src/aiopikvm/_ws.py
async def events(self) -> AsyncIterator[dict[str, Any]]:
    """Iterate over incoming events.

    Every event is a ``{"event_type": ..., "event": ...}`` object. The
    first one is always ``loop``, carrying the kvmd version; after it
    each subsystem sends its current state once, interleaved with the
    broadcasts other clients trigger, so nothing but ``loop`` arrives in
    a guaranteed order. See the WebSocket guide for the full list.

    Binary frames do not appear here. The only one kvmd sends is the
    answer to [`ping()`][aiopikvm.PiKVMWebSocket.ping], which that method
    consumes; anything else on that channel is logged and dropped, since a
    binary frame is an operation number and a payload, not an event.

    The iteration ends when either side closes the connection cleanly. A
    connection that breaks instead — the device rebooting, the network
    going away, kvmd restarting — raises, because a caller that only sees
    the loop finish cannot tell "kvmd has nothing more to say" from
    "the events stopped arriving".

    Nothing is read here: the socket is drained by a task of its own from
    the moment it opens, and this hands out what that task collected. A
    consumer slower than kvmd is broadcasting therefore falls behind in
    memory rather than on the wire — and once
    1024 events are waiting, the oldest are dropped, merged into the next
    event of their kind so that no field a
    [`states()`][aiopikvm.PiKVMWebSocket.states] snapshot rests on is lost.

    Yields:
        Parsed JSON event dictionaries.

    Raises:
        WebSocketError: The client is not connected, or the connection
            broke instead of closing cleanly.
    """
    self._ensure_connected()
    self._start_reader()
    while True:
        while self._pending:
            yield self._next_event()
        reader = self._reader
        if reader is None or reader.done():
            self._raise_failure()
            return
        # Cleared before the recheck, so an event buffered between the two
        # wakes this up rather than being waited past.
        self._wakeup.clear()
        if self._pending or reader.done():
            continue
        await self._wakeup.wait()

states() async

Iterate over the device state the events add up to.

kvmd broadcasts a subsystem's state in pieces: the first event for each carries all of it, and every event after that carries only what changed — a streamer event with nothing but streamer in it, an info event with nothing but uptime. Validating one of those on its own fails, because most of the model is simply not in it. This merges each event into what the same subsystem said before and hands back the whole picture, typed, once per event that changed something.

Only the events that say something about the device produce a snapshot; loop and pong do not, and neither does an event type this release does not know. Nor does one it does know but cannot place on DeviceState — the models and the fields are meant to agree and tests say they do, so that shows up as a subsystem which never arrives rather than as an exception. The kvmd version the loop event carries is on version.

Everything events() does about the connection applies here, and the two cannot be iterated over the same socket at once: this is events() with the states built on top.

Yields:

Type Description
AsyncIterator[DeviceState]

The device as of the event that has just arrived.

What has been merged so far is on state, so a loop that was left can be resumed and the last snapshot is readable from outside it.

Raises:

Type Description
ResponseError

A merged payload did not match its model, which means a kvmd this release does not describe correctly. kvmd sends every subsystem in full when the socket opens, so a partial update always has something to merge into — whether it was this call that took the full one, an earlier states(), or events().

WebSocketError

The client is not connected, or the connection broke instead of closing cleanly.

Source code in src/aiopikvm/_ws.py
async def states(self) -> AsyncIterator[DeviceState]:
    """Iterate over the device state the events add up to.

    kvmd broadcasts a subsystem's state in pieces: the first event for
    each carries all of it, and every event after that carries only what
    changed — a ``streamer`` event with nothing but ``streamer`` in it, an
    ``info`` event with nothing but ``uptime``. Validating one of those on
    its own fails, because most of the model is simply not in it. This
    merges each event into what the same subsystem said before and hands
    back the whole picture, typed, once per event that changed something.

    Only the events that say something about the device produce a
    snapshot; ``loop`` and ``pong`` do not, and neither does an event type
    this release does not know. Nor does one it *does* know but cannot
    place on [`DeviceState`][aiopikvm.DeviceState] — the models and the
    fields are meant to agree and tests say they do, so that shows up as a
    subsystem which never arrives rather than as an exception. The kvmd
    version the ``loop`` event carries is on
    [`version`][aiopikvm.PiKVMWebSocket.version].

    Everything [`events()`][aiopikvm.PiKVMWebSocket.events] does about the
    connection applies here, and the two cannot be iterated over the same
    socket at once: this is [`events()`][aiopikvm.PiKVMWebSocket.events]
    with the states built on top.

    Yields:
        The device as of the event that has just arrived.

    What has been merged so far is on
    [`state`][aiopikvm.PiKVMWebSocket.state], so a loop that was left can
    be resumed and the last snapshot is readable from outside it.

    Raises:
        ResponseError: A merged payload did not match its model, which
            means a kvmd this release does not describe correctly. kvmd
            sends every subsystem in full when the socket opens, so a
            partial update always has something to merge into — whether it
            was this call that took the full one, an earlier `states()`,
            or [`events()`][aiopikvm.PiKVMWebSocket.events].
        WebSocketError: The client is not connected, or the connection
            broke instead of closing cleanly.
    """
    async for event in self.events():
        event_type = event.get("event_type")
        payload = event.get("event")
        if not isinstance(event_type, str) or not isinstance(payload, dict):
            continue
        if event_type == "clients":
            count = payload.get("count")
            if not isinstance(count, int):
                continue
            self._state = dataclasses.replace(
                self._state, updated=event_type, clients=count
            )
        elif event_type in _STATE_MODELS and event_type in _STATE_FIELDS:
            # The second test is for a table entry with nowhere to land,
            # which `replace()` answers with a bare `TypeError` at the
            # caller. Two tests keep the table and the fields together, so
            # this is for the build that shipped without them — skipped
            # like any other event this release cannot place (#143).
            #
            # Merged as the event came off the buffer, so this is that
            # subsystem's whole state and not only what just changed.
            self._state = dataclasses.replace(
                self._state,
                updated=event_type,
                **{event_type: _as_state(event_type, self._seen[event_type])},
            )
        else:
            continue
        yield self._state

ping(*, timeout=10.0) async

Ask kvmd for a pong, and wait for it.

This is kvmd's application-level ping, not the protocol one: the request goes through the same event loop that dispatches HID input and broadcasts state, so the answer means that loop is running, not merely that something on the other end still holds a TCP socket open. Keeping the connection alive needs neither — websockets sends a protocol ping every 20 seconds by itself and drops the connection when one goes unanswered for another 20, which is what turns a silently dead link into a WebSocketError. That keepalive tells a dead link from a live one only because this connection is always being read: a pong is acknowledged where the frames are parsed, so a socket left unread fails its own keepalive.

The answer arrives on the socket like everything else, so the task reading it is what hands the pong over, and the events it passes on the way are kept for the next events() call. The round trip is measured from the frame going out to the pong being read, which is not affected by how fast anything consumes events().

Parameters:

Name Type Description Default
timeout float

Seconds to wait for the answer.

10.0

Returns:

Type Description
float

The round trip in seconds.

Raises:

Type Description
WebSocketError

The client is not connected, the connection broke or closed before the answer arrived, or kvmd did not answer within timeout.

Source code in src/aiopikvm/_ws.py
async def ping(self, *, timeout: float = 10.0) -> float:
    """Ask kvmd for a pong, and wait for it.

    This is kvmd's application-level ping, not the protocol one: the
    request goes through the same event loop that dispatches HID input and
    broadcasts state, so the answer means that loop is running, not merely
    that something on the other end still holds a TCP socket open. Keeping
    the connection alive needs neither — *websockets* sends a protocol
    ping every 20 seconds by itself and drops the connection when one goes
    unanswered for another 20, which is what turns a silently dead link
    into a [`WebSocketError`][aiopikvm.WebSocketError]. That keepalive
    tells a dead link from a live one only because this connection is
    always being read: a pong is acknowledged where the frames are parsed,
    so a socket left unread fails its own keepalive.

    The answer arrives on the socket like everything else, so the task
    reading it is what hands the pong over, and the events it passes on
    the way are kept for the next
    [`events()`][aiopikvm.PiKVMWebSocket.events] call. The round trip is
    measured from the frame going out to the pong being read, which is not
    affected by how fast anything consumes
    [`events()`][aiopikvm.PiKVMWebSocket.events].

    Args:
        timeout: Seconds to wait for the answer.

    Returns:
        The round trip in seconds.

    Raises:
        WebSocketError: The client is not connected, the connection broke
            or closed before the answer arrived, or kvmd did not answer
            within *timeout*.
    """
    self._ensure_connected()
    self._start_reader()
    loop = asyncio.get_running_loop()
    waiter: asyncio.Future[float] = loop.create_future()
    self._pong_waiters.append(waiter)
    try:
        sent_at = loop.time()
        if self._binary:
            await self._send_bin(_OP_PING, b"", "ping")
        else:
            await self._send_event("ping", {})
        async with asyncio.timeout(timeout):
            answered_at = await waiter
        return answered_at - sent_at
    except TimeoutError as exc:
        raise WebSocketError(
            f"kvmd did not answer the ping within {timeout} s"
        ) from exc
    except WebSocketError:
        # The caller has been told the socket is gone, so __aexit__ has
        # nothing left to report.
        self._reported = True
        raise
    finally:
        if waiter in self._pong_waiters:
            self._pong_waiters.remove(waiter)

send_key(key, *, state, finish=False) async

Send a keyboard key event.

Parameters:

Name Type Description Default
key str

Key name, one of kvmd's web names such as "KeyA" or "ControlLeft"; aiopikvm.resources.hid.KEY_NAMES holds every one of them. kvmd ignores an event it cannot map, and over this socket it does so without an answer of any kind — there is no 400 here to tell a typo from a keystroke that landed.

required
state bool

True for press, False for release. kvmd holds the key until the release arrives.

required
finish bool

Ask kvmd to release the key in the same event that pressed it, so a socket that goes away mid-keystroke leaves nothing held. It goes out only on a press, the only place kvmd acts on it; HIDResource.send_key and the HID guide have the keys it exempts.

False

Raises:

Type Description
ConfigurationError

The key name cannot go into a binary frame, being empty, non-ASCII, or longer than 32 bytes. kvmd has no such name, and the frame would be dropped without a word.

WebSocketError

The client is not connected, or the connection broke before the frame could be sent.

Source code in src/aiopikvm/_ws.py
async def send_key(self, key: str, *, state: bool, finish: bool = False) -> None:
    """Send a keyboard key event.

    Args:
        key: Key name, one of kvmd's web names such as ``"KeyA"`` or
            ``"ControlLeft"``; ``aiopikvm.resources.hid.KEY_NAMES`` holds
            every one of them. kvmd ignores an event it cannot map, and
            over this socket it does so without an answer of any kind —
            there is no 400 here to tell a typo from a keystroke that
            landed.
        state: ``True`` for press, ``False`` for release. kvmd holds the
            key until the release arrives.
        finish: Ask kvmd to release the key in the same event that
            pressed it, so a socket that goes away mid-keystroke leaves
            nothing held. It goes out only on a press, the only place
            kvmd acts on it; ``HIDResource.send_key`` and the HID guide
            have the keys it exempts.

    Raises:
        ConfigurationError: The key name cannot go into a binary frame,
            being empty, non-ASCII, or longer than 32 bytes. kvmd has no
            such name, and the frame would be dropped without a word.
        WebSocketError: The client is not connected, or the connection
            broke before the frame could be sent.
    """
    # kvmd acts on the flag only on a press, so on a release it is dead
    # weight. Dropping it here keeps the frame to the two values kvmd
    # reads, rather than sending a bit it will ignore.
    finish = finish and state
    if self._binary:
        flags = (0b01 if state else 0) | (0b10 if finish else 0)
        await self._send_bin(
            _OP_KEY, bytes([flags]) + _name_bytes(key, "Key"), "key"
        )
    else:
        event: dict[str, Any] = {"key": key, "state": state}
        if finish:
            # kvmd defaults it to False, so leaving it out is the same
            # event a client that never heard of the flag would send.
            event["finish"] = True
        await self._send_event("key", event)

send_mouse_move(to_x, to_y) async

Move the mouse to an absolute position.

The coordinates are not pixels. kvmd works in a resolution-independent space from -32768 (left, top) to 32767 (right, bottom), so 0, 0 is the middle of the screen and send_mouse_move(500, 300) lands a hair right of and below it — not 500 pixels from the corner. Convert from pixels with round(x / (width - 1) * 65535) - 32768.

Values outside the range are clamped, by kvmd for a JSON event and here for a binary one, which has nowhere to put them.

Parameters:

Name Type Description Default
to_x int

Horizontal position, -32768 to 32767.

required
to_y int

Vertical position, -32768 to 32767.

required

Raises:

Type Description
WebSocketError

The client is not connected, or the connection broke before the frame could be sent.

Source code in src/aiopikvm/_ws.py
async def send_mouse_move(self, to_x: int, to_y: int) -> None:
    """Move the mouse to an absolute position.

    The coordinates are not pixels. kvmd works in a resolution-independent
    space from -32768 (left, top) to 32767 (right, bottom), so ``0, 0`` is
    the middle of the screen and ``send_mouse_move(500, 300)`` lands a
    hair right of and below it — not 500 pixels from the corner. Convert
    from pixels with ``round(x / (width - 1) * 65535) - 32768``.

    Values outside the range are clamped, by kvmd for a JSON event and
    here for a binary one, which has nowhere to put them.

    Args:
        to_x: Horizontal position, -32768 to 32767.
        to_y: Vertical position, -32768 to 32767.

    Raises:
        WebSocketError: The client is not connected, or the connection
            broke before the frame could be sent.
    """
    if self._binary:
        packed = struct.pack(
            ">hh",
            _clamp(to_x, _MOVE_MIN, _MOVE_MAX),
            _clamp(to_y, _MOVE_MIN, _MOVE_MAX),
        )
        await self._send_bin(_OP_MOUSE_MOVE, packed, "mouse_move")
    else:
        await self._send_event("mouse_move", {"to": {"x": to_x, "y": to_y}})

send_mouse_button(button, state) async

Send a mouse button event.

Parameters:

Name Type Description Default
button MouseButton

Button name, one of aiopikvm.resources.hid.MouseButton. A name kvmd does not know is dropped inside its handler with no answer of any kind, the way a bad key name is — there is no 400 on this socket to tell a typo from a click that landed.

required
state bool

True for press, False for release.

required

Raises:

Type Description
ConfigurationError

The button name cannot go into a binary frame, being empty, non-ASCII, or longer than 32 bytes. kvmd has no such name, and the frame would be dropped without a word.

WebSocketError

The client is not connected, or the connection broke before the frame could be sent.

Source code in src/aiopikvm/_ws.py
async def send_mouse_button(self, button: MouseButton, state: bool) -> None:
    """Send a mouse button event.

    Args:
        button: Button name, one of
            ``aiopikvm.resources.hid.MouseButton``. A name kvmd does not
            know is dropped inside its handler with no answer of any
            kind, the way a bad key name is — there is no 400 on this
            socket to tell a typo from a click that landed.
        state: ``True`` for press, ``False`` for release.

    Raises:
        ConfigurationError: The button name cannot go into a binary frame,
            being empty, non-ASCII, or longer than 32 bytes. kvmd has no
            such name, and the frame would be dropped without a word.
        WebSocketError: The client is not connected, or the connection
            broke before the frame could be sent.
    """
    if self._binary:
        flags = 0b01 if state else 0
        await self._send_bin(
            _OP_MOUSE_BUTTON,
            bytes([flags]) + _name_bytes(button, "Mouse button"),
            "mouse_button",
        )
    else:
        await self._send_event("mouse_button", {"button": button, "state": state})

send_mouse_wheel(delta_x, delta_y) async

Send a mouse wheel event.

Deltas are steps in kvmd's own range, -127 to 127, clamped rather than rejected — by kvmd for a JSON event and here for a binary one, which has nowhere to put a larger number — and carried in the HID wheel field. They are not the browser's pixel deltas: a browser reports a scroll-down gesture as a positive deltaY, and kvmd's own web UI negates it and sizes it by its scroll-rate setting (1 to 25, 5 by default), so the gesture reaches the device as delta_y = -5.

Parameters:

Name Type Description Default
delta_x int

Horizontal step, -127 to 127. It needs a backend with a horizontal wheel behind it, and in kvmd 4.206 only otg has one, while its horizontal_wheel option is on — the default. serial, spi, ch9329 and bt drop it without a word, and which way a positive step pans is not settled here.

required
delta_y int

Vertical step, -127 to 127. Negative scrolls down on a host with the usual wheel mapping. ch9329 keeps only the sign and sends one detent, a zero counting as negative, so the size is lost there.

required

Raises:

Type Description
WebSocketError

The client is not connected, or the connection broke before the frame could be sent.

Source code in src/aiopikvm/_ws.py
async def send_mouse_wheel(self, delta_x: int, delta_y: int) -> None:
    """Send a mouse wheel event.

    Deltas are steps in kvmd's own range, -127 to 127, clamped rather
    than rejected — by kvmd for a JSON event and here for a binary one,
    which has nowhere to put a larger number — and carried in the HID
    wheel field. They are not the browser's pixel deltas: a browser
    reports a scroll-down gesture as a positive ``deltaY``, and kvmd's own
    web UI negates it and sizes it by its scroll-rate setting (1 to 25, 5
    by default), so the gesture reaches the device as ``delta_y = -5``.

    Args:
        delta_x: Horizontal step, -127 to 127. It needs a backend with a
            horizontal wheel behind it, and in kvmd 4.206 only ``otg`` has
            one, while its ``horizontal_wheel`` option is on — the
            default. ``serial``, ``spi``, ``ch9329`` and ``bt`` drop it
            without a word, and which way a positive step pans is not
            settled here.
        delta_y: Vertical step, -127 to 127. Negative scrolls down on a
            host with the usual wheel mapping. ``ch9329`` keeps only the
            sign and sends one detent, a zero counting as negative, so the
            size is lost there.

    Raises:
        WebSocketError: The client is not connected, or the connection
            broke before the frame could be sent.
    """
    await self._send_delta(_OP_MOUSE_WHEEL, "mouse_wheel", delta_x, delta_y)

send_mouse_wheel_batch(deltas, *, squash=False) async

Send several wheel steps in one frame.

A step means what it does in send_mouse_wheel(), backends and directions included.

Parameters:

Name Type Description Default
deltas Iterable[tuple[int, int]]

(delta_x, delta_y) steps, in the order they happened. An empty batch is a frame kvmd does nothing with.

required
squash bool

Ask kvmd to add the steps together instead of reporting each one. See send_mouse_relative_batch(), which squashes by the same rule.

False

Raises:

Type Description
WebSocketError

The client is not connected, or the connection broke before the frame could be sent.

Source code in src/aiopikvm/_ws.py
async def send_mouse_wheel_batch(
    self, deltas: Iterable[tuple[int, int]], *, squash: bool = False
) -> None:
    """Send several wheel steps in one frame.

    A step means what it does in ``send_mouse_wheel()``, backends and
    directions included.

    Args:
        deltas: ``(delta_x, delta_y)`` steps, in the order they happened.
            An empty batch is a frame kvmd does nothing with.
        squash: Ask kvmd to add the steps together instead of reporting
            each one. See
            [`send_mouse_relative_batch()`][aiopikvm.PiKVMWebSocket.send_mouse_relative_batch],
            which squashes by the same rule.

    Raises:
        WebSocketError: The client is not connected, or the connection
            broke before the frame could be sent.
    """
    await self._send_deltas(_OP_MOUSE_WHEEL, "mouse_wheel", deltas, squash=squash)

send_mouse_relative(delta_x, delta_y) async

Move the mouse by an amount, rather than to a position.

This needs the mouse in a relative mode: kvmd drops a relative event while the current mouse is absolute, and drops send_mouse_move() while it is relative — in both cases without a word to the sender. kvm.hid.set_params(mouse_output="usb_rel") switches it, and HIDState.mouse.absolute says which mode is on.

Deltas are steps in kvmd's own range, -127 to 127, clamped rather than rejected — by kvmd for a JSON event and here for a binary one, which has nowhere to put a larger number. A gesture longer than one step therefore takes several events, which is what send_mouse_relative_batch() is for.

Parameters:

Name Type Description Default
delta_x int

Horizontal step, -127 to 127. Positive moves right.

required
delta_y int

Vertical step, -127 to 127. Positive moves down.

required

Raises:

Type Description
WebSocketError

The client is not connected, or the connection broke before the frame could be sent.

Source code in src/aiopikvm/_ws.py
async def send_mouse_relative(self, delta_x: int, delta_y: int) -> None:
    """Move the mouse by an amount, rather than to a position.

    This needs the mouse in a relative mode: kvmd drops a relative event
    while the current mouse is absolute, and drops
    [`send_mouse_move()`][aiopikvm.PiKVMWebSocket.send_mouse_move] while
    it is relative — in both cases without a word to the sender.
    ``kvm.hid.set_params(mouse_output="usb_rel")`` switches it, and
    ``HIDState.mouse.absolute`` says which mode is on.

    Deltas are steps in kvmd's own range, -127 to 127, clamped rather than
    rejected — by kvmd for a JSON event and here for a binary one, which
    has nowhere to put a larger number. A gesture longer than one step
    therefore takes several events, which is what
    [`send_mouse_relative_batch()`][aiopikvm.PiKVMWebSocket.send_mouse_relative_batch]
    is for.

    Args:
        delta_x: Horizontal step, -127 to 127. Positive moves right.
        delta_y: Vertical step, -127 to 127. Positive moves down.

    Raises:
        WebSocketError: The client is not connected, or the connection
            broke before the frame could be sent.
    """
    await self._send_delta(_OP_MOUSE_RELATIVE, "mouse_relative", delta_x, delta_y)

send_mouse_relative_batch(deltas, *, squash=False) async

Send several relative steps in one frame.

One frame for a burst of movement is what kvmd's own web UI does: it collects the deltas a mouse produced between two screen refreshes and sends them together, rather than a frame per browser event.

With squash, kvmd adds consecutive steps up instead of reporting each one, and starts a new sum whenever the running total would leave the -127 to 127 a HID report can carry. Fewer reports reach the host that way, at the cost of the shape of the path between them — and a batch that adds up to nothing sends nothing at all, since kvmd drops a final sum of (0, 0).

Parameters:

Name Type Description Default
deltas Iterable[tuple[int, int]]

(delta_x, delta_y) steps, in the order they happened. An empty batch is a frame kvmd does nothing with.

required
squash bool

Add the steps together where they fit into one report.

False

Raises:

Type Description
WebSocketError

The client is not connected, or the connection broke before the frame could be sent.

Source code in src/aiopikvm/_ws.py
async def send_mouse_relative_batch(
    self, deltas: Iterable[tuple[int, int]], *, squash: bool = False
) -> None:
    """Send several relative steps in one frame.

    One frame for a burst of movement is what kvmd's own web UI does: it
    collects the deltas a mouse produced between two screen refreshes and
    sends them together, rather than a frame per browser event.

    With *squash*, kvmd adds consecutive steps up instead of reporting
    each one, and starts a new sum whenever the running total would leave
    the -127 to 127 a HID report can carry. Fewer reports reach the host
    that way, at the cost of the shape of the path between them — and a
    batch that adds up to nothing sends nothing at all, since kvmd drops a
    final sum of ``(0, 0)``.

    Args:
        deltas: ``(delta_x, delta_y)`` steps, in the order they happened.
            An empty batch is a frame kvmd does nothing with.
        squash: Add the steps together where they fit into one report.

    Raises:
        WebSocketError: The client is not connected, or the connection
            broke before the frame could be sent.
    """
    await self._send_deltas(
        _OP_MOUSE_RELATIVE, "mouse_relative", deltas, squash=squash
    )

KvmdVersion

The kvmd protocol version from the loop event.

kvmd sends it as the first thing on every connection, and it is the only version signal the socket carries. Being a tuple, it compares the way a version should: ws.version >= (4, 100).

Attributes:

Name Type Description
major int

Major version, 4 for the kvmd 4.x series.

minor int

Minor version, e.g. 206 for kvmd 4.206.

Source code in src/aiopikvm/_ws.py
class KvmdVersion(NamedTuple):
    """The kvmd protocol version from the ``loop`` event.

    kvmd sends it as the first thing on every connection, and it is the only
    version signal the socket carries. Being a tuple, it compares the way a
    version should: ``ws.version >= (4, 100)``.

    Attributes:
        major: Major version, ``4`` for the kvmd 4.x series.
        minor: Minor version, e.g. ``206`` for kvmd 4.206.
    """

    major: int
    minor: int

DeviceState dataclass

Everything the socket has said about the device so far.

One of these comes out of PiKVMWebSocket.states() per event that changed something, with the subsystem that event was about validated against the same model its REST endpoint returns. A field is None until kvmd has sent that subsystem — which it does for all of them when the socket opens, except on a device that has the subsystem switched off.

Attributes:

Name Type Description
updated str

Event type behind this snapshot, e.g. "atx". Empty on a snapshot nothing has been merged into yet.

atx ATXState | None

Power and LED state, as GET /api/atx returns it.

gpio GPIOState | None

GPIO scheme, view and pin state.

hid HIDState | None

Keyboard, mouse and jiggler state.

hid_keymaps HIDKeymaps | None

Keyboard layouts installed on the device.

msd MSDState | None

Mass storage drive and storage state.

ocr OCRInfo | None

Whether OCR is enabled, and the languages it has.

streamer StreamerState | None

Streamer state, features, limits and parameters.

switch SwitchState | None

PiKVM Switch model, port state and summary.

clients int | None

How many connected sessions asked kvmd for video. kvmd broadcasts it to everybody whenever a session comes or goes, this one included.

info InfoState | None

The /api/info subsystems, merged as they arrive. kvmd sends one submanager at a time — uptime, health, system — so every attribute of it is optional and fills in as the events come. It is the per-submanager shape, never the legacy one.

Source code in src/aiopikvm/_ws.py
@dataclasses.dataclass(frozen=True, slots=True)
class DeviceState:
    """Everything the socket has said about the device so far.

    One of these comes out of
    [`PiKVMWebSocket.states()`][aiopikvm.PiKVMWebSocket.states] per event that
    changed something, with the subsystem that event was about validated
    against the same model its REST endpoint returns. A field is ``None``
    until kvmd has sent that subsystem — which it does for all of them when
    the socket opens, except on a device that has the subsystem switched off.

    Attributes:
        updated: Event type behind this snapshot, e.g. ``"atx"``. Empty on a
            snapshot nothing has been merged into yet.
        atx: Power and LED state, as ``GET /api/atx`` returns it.
        gpio: GPIO scheme, view and pin state.
        hid: Keyboard, mouse and jiggler state.
        hid_keymaps: Keyboard layouts installed on the device.
        msd: Mass storage drive and storage state.
        ocr: Whether OCR is enabled, and the languages it has.
        streamer: Streamer state, features, limits and parameters.
        switch: PiKVM Switch model, port state and summary.
        clients: How many connected sessions asked kvmd for video. kvmd
            broadcasts it to everybody whenever a session comes or goes, this
            one included.
        info: The ``/api/info`` subsystems, merged as they arrive. kvmd sends
            one submanager at a time — ``uptime``, ``health``, ``system`` —
            so every attribute of it is optional and fills in as the events
            come. It is the per-submanager shape, never the legacy one.
    """

    updated: str = ""
    atx: ATXState | None = None
    gpio: GPIOState | None = None
    hid: HIDState | None = None
    hid_keymaps: HIDKeymaps | None = None
    msd: MSDState | None = None
    ocr: OCRInfo | None = None
    streamer: StreamerState | None = None
    switch: SwitchState | None = None
    clients: int | None = None
    info: InfoState | None = None