pax_global_header00006660000000000000000000000064152027020750014512gustar00rootroot0000000000000052 comment=19af4c58a2e8de4f82634bcf65974ebad5e69e2d AGuyMarc-lsdisplay-1fd04f4/000077500000000000000000000000001520270207500155765ustar00rootroot00000000000000AGuyMarc-lsdisplay-1fd04f4/.gitignore000066400000000000000000000001321520270207500175620ustar00rootroot00000000000000__pycache__/ *.pyc *.egg-info/ dist/ build/ *.deb *.info debian_backup/ *.bak notes_*.md AGuyMarc-lsdisplay-1fd04f4/EDID_ISSUES.md000066400000000000000000000112571520270207500177260ustar00rootroot00000000000000# EDID Issues — Samsung Smart TVs ## Problem Samsung Smart TVs report incorrect information in their EDID (Extended Display Identification Data), making automatic identification unreliable: | Field | Expected | Samsung reports | Impact | |-------|----------|-----------------|--------| | **Monitor name** | Model name (e.g. TQ65QN800DTXXC) | Generic "SAMSUNG" | Cannot identify model | | **Serial number** | Unique per device | Same generic number (e.g. 16780800) across all TVs | Cannot distinguish two Samsung TVs | | **Physical size** | Panel dimensions | Chassis/bezel dimensions | Incorrect diagonal calculation (65" TV reports 85") | | **Product code** | — | Hex code (e.g. 0x7513) | No public database to map to model name | ### Example: Samsung Neo QLED 8K 65" ``` EDID bytes 21-22 (physical size): 142cm x 80cm → 85" diagonal (WRONG) EDID detailed timing descriptor: 1872mm x 1053mm → 85" (WRONG) Actual panel size: 1440mm x 810mm → 65" (CORRECT) EDID monitor name descriptor: "SAMSUNG" (generic, not model name) EDID serial number: 16780800 (same on all Samsung TVs tested) ``` The EDID reports the **total chassis dimensions** including bezel and stand mounting area, not the actual display panel size. This is arguably a violation of the EDID specification (VESA E-EDID Standard, section 3.10.2) which states that the image size should reflect the "viewable image area." ### Affected Samsung models tested - **TQ65QN800DTXXC** (Neo QLED 8K 65") — product code 0x7513, reports 85" - **QE32Q50A** (Q50AE 32") — product code 0x71A5, reports 55" ### Other manufacturers Iiyama PL2792Q and PL2793Q (27" monitors) report **correct** EDID data: correct model name, unique serial numbers, and accurate physical dimensions. **Note:** We have not been able to test other TV manufacturers (LG, Sony, etc.) to determine if this is a Samsung-specific issue or an industry-wide problem with Smart TVs. ## Workaround: `overrides.json` `lsdisplay` supports a manual override file to correct Samsung EDID data. ### Configuration file locations (first found wins) 1. `~/.config/lsdisplay/overrides.json` (user) 2. `/etc/lsdisplay/overrides.json` (system-wide) ### Format Key = manufacturer PNP ID (3 chars) + EDID product code (4 hex digits uppercase). ```json { "_comment": "Override incorrect EDID data. Key = MFG_ID + product_code_hex", "SAM7513": { "model": "TQ65QN800DTXXC", "diagonal": 65, "serial": "94:e6:ba:dd:9a:7a", "note": "Samsung Neo QLED 8K 65\" Salon" }, "SAM71A5": { "model": "QE32Q50A", "diagonal": 32, "serial": "bc:45:5b:e4:e8:13", "note": "Samsung Q50AE 32\" Loggia" } } ``` ### Automatic scan: `lsdisplay --scan` Samsung Smart TVs expose an HTTP API on port 8001 that provides accurate device information (model name, MAC address, resolution, firmware, etc.). `lsdisplay --scan` exploits this to auto-populate `overrides.json`: 1. Scans the local network for devices with port 8001 open 2. Queries `http://:8001/api/v2/` on each 3. Matches network TVs with connected EDID displays by closest resolution 4. Extracts real model name, diagonal (from TV name), and MAC address 5. Writes `overrides.json` automatically ```bash lsdisplay --scan # auto-detect local subnet lsdisplay --scan 192.168.1.0/24 # scan specific subnet ``` ### How to find the EDID product code for a new TV ```bash python3 -c " import os for entry in os.listdir('/sys/class/drm'): edid_path = f'/sys/class/drm/{entry}/edid' if not os.path.exists(edid_path): continue with open(edid_path, 'rb') as f: data = f.read() if len(data) < 128: continue m1, m2 = data[8], data[9] mfg = chr(((m1>>2)&0x1F)+64) + chr(((m1&0x3)<<3|(m2>>5))+64) + chr((m2&0x1F)+64) prod = data[10] | (data[11] << 8) if mfg == 'SAM': name = '' for i in range(4): o = 54 + i*18 if data[o]==0 and data[o+1]==0 and data[o+3]==0xFC: name = data[o+5:o+18].decode('ascii',errors='replace').strip() print(f'{entry}: SAM{prod:04X} name={name}') " ``` ## Recommendation to Samsung Samsung could improve the user and developer experience by: 1. **Reporting the real model name** in EDID descriptor tag 0xFC (e.g. "TQ65QN800D" instead of "SAMSUNG") 2. **Using unique serial numbers** in EDID descriptor tag 0xFF or bytes 12-15 (the MAC address would be a good candidate) 3. **Reporting accurate panel dimensions** in the detailed timing descriptors (panel size, not chassis size) 4. **Publishing a product code database** mapping EDID hex codes to model names These are trivial firmware changes that would benefit all Linux users, HTPC builders, media centers, and display management tools. AGuyMarc-lsdisplay-1fd04f4/LICENSE000066400000000000000000000431001520270207500166010ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, see . Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Moe Ghoul, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. AGuyMarc-lsdisplay-1fd04f4/README.md000066400000000000000000000070331520270207500170600ustar00rootroot00000000000000# lsdisplay List connected displays with details and ASCII layout diagram. Like `lsusb`, `lspci`, `lscpu`, `lsblk`, `lsmem` — but for displays. A useful CLI tool for Linux users and admins. Zero-dependency — just Python 3.7+ and `/sys/class/drm`. **Companion tool:** [`lsgpu`](https://github.com/AGuyMarc/lsgpu) — list the GPUs that drive those displays (with NVIDIA/AMD stats). ## Features - **EDID parsing** from `/sys/class/drm/*/edid`: manufacturer, model, serial number - **Resolution, position, rotation** via xrandr (fallback: kscreen-doctor, wlr-randr) - **ASCII art layout diagram** with correct proportions - **JSON output** for scripting - Works on X11 and Wayland (KDE, Sway, etc.) - No external dependencies, Python 3.7+ ## Installation ### Debian / Ubuntu (.deb) Download the `.deb` from the [Releases page](https://github.com/AGuyMarc/lsdisplay/releases/latest), then: ```bash sudo dpkg -i lsdisplay_0.2.1-1_all.deb ``` The package installs `/usr/bin/lsdisplay`, the man page `lsdisplay(1)`, and documentation. ### Arch Linux / Manjaro (AUR) Available in the AUR thanks to [@seraf1](https://aur.archlinux.org/account/seraf1): ```bash yay -S lsdisplay-git ``` Package page: https://aur.archlinux.org/packages/lsdisplay-git ### From source ```bash git clone https://github.com/AGuyMarc/lsdisplay cd lsdisplay # System-wide sudo cp lsdisplay.py /usr/local/bin/lsdisplay sudo chmod +x /usr/local/bin/lsdisplay # Or user-local cp lsdisplay.py ~/.local/bin/lsdisplay chmod +x ~/.local/bin/lsdisplay ``` ## Usage ```bash lsdisplay # Full output with layout diagram lsdisplay --short # Compact one-line-per-display lsdisplay --json # JSON output for scripting lsdisplay --no-layout # Skip the ASCII art diagram lsdisplay --version # Show version ``` ## Example output ``` CONNECTED DISPLAYS ================== HDMI-A-2 1440x2560+1441+0 27" 75Hz Iiyama PL2792Q HDMI S/N:1152032422031 rot=left DP-4 1440x2560+0+0 27" 75Hz Iiyama PL2792Q DisplayPort S/N:1152031921274 rot=left [PRIMARY] HDMI-A-5 5376x3024+0+2561 65" 60Hz Samsung QN800D HDMI S/N:16780800 Total: 3 display(s) connected LAYOUT ====== +--------------+ +--------------+ | | | | | DP-4* | | HDMI-A-2 | | | | | +--------------+ +--------------+ +------------------------------+ | | | HDMI-A-5 | | | +------------------------------+ ``` ## Requirements - Python 3.7+ - Linux with `/sys/class/drm` (any modern kernel) - `xrandr` (X11) or `kscreen-doctor` (KDE Wayland) or `wlr-randr` (wlroots Wayland) ## How it works 1. Scans `/sys/class/drm/card*/edid` for raw EDID data 2. Parses EDID to extract PNP manufacturer ID, monitor name, serial number 3. Maps PNP IDs to human-readable names (Samsung, Dell, Iiyama, etc.) 4. Uses `xrandr` output for resolution, position, rotation 5. Draws ASCII art layout proportional to actual pixel dimensions ## See also Hardware enumeration `ls*` family on Linux: - [`lsgpu`](https://github.com/AGuyMarc/lsgpu) — GPUs (NVIDIA/AMD/Intel) with stats and output mapping (companion to this tool) - `lscpu` — CPU architecture info - `lspci` — PCI devices - `lsusb` — USB devices - `lsblk` — block devices (disks, partitions) - `lsmem` — memory ranges - `lsmod` — kernel modules - `lsipc` — IPC facilities - `lsns` — namespaces ## License GPL-2.0. See [LICENSE](LICENSE) for the full text. AGuyMarc-lsdisplay-1fd04f4/debian/000077500000000000000000000000001520270207500170205ustar00rootroot00000000000000AGuyMarc-lsdisplay-1fd04f4/debian/changelog000066400000000000000000000073011520270207500206730ustar00rootroot00000000000000lsdisplay (0.2.1-1) unstable; urgency=medium * Fix debian/control: bump minimum Python from 3.6 to 3.7, aligning with setup.py's python_requires=">=3.7" (the code uses f-strings and dataclasses that require 3.7+; the 3.6 entry was a stale leftover). * README.md: installation example now uses the exact filename (lsdisplay_0.2.1-1_all.deb) instead of an X.Y.Z placeholder. * Refresh docstring example date in lsdisplay.py. * Cleanup: move RELEASING.md, old .deb/.bak/debian_backup and other internal notes to a local .info/ directory (not shipped). -- Guy-Marc APRIN <2026@gm.casa> Mon, 18 May 2026 22:15:00 +0200 lsdisplay (0.2.0-1) unstable; urgency=medium * Add CLI override management (CRUD): --override-list, --override-add (interactive wizard), --override-set with --override-model/diagonal/note (programmatic), and --override-remove. Wizard reads detected displays, derives the MFG_ID+product_code_hex key, and prompts for new values with sensible defaults from the current EDID. * Refactor: extract _save_overrides() helper from --scan code path so all write-paths share the same persistence logic. * Document new commands in argparse epilog examples. * Bump version to 0.2.0 (minor bump: new public CLI surface). -- Guy-Marc APRIN <2026@gm.casa> Sun, 17 May 2026 18:45:00 +0200 lsdisplay (0.1.4-1) unstable; urgency=medium * Fix Debian packaging regression introduced in 0.1.3: - Install man page via debian/lsdisplay.manpages (was missing from .deb). - Install README.md via debian/lsdisplay.docs. - Add Debian-format debian/copyright file. - Move /usr/bin/lsdisplay symlink from postinst hack to debian/lsdisplay.links (now visible in dpkg-deb -c, no runtime hack). - Remove now-unused debian/postinst (debhelper auto-handles man-db). * Fix Maintainer in debian/control: 'Aprin' -> 'APRIN' to match the upstream convention applied across all source headers. -- Guy-Marc APRIN <2026@gm.casa> Fri, 15 May 2026 19:30:00 +0200 lsdisplay (0.1.3-1) unstable; urgency=medium * Fix ASCII layout for non-uniform vertical arrangements: replace the row-banding renderer with a 2D character canvas. Portrait monitors whose y-range straddles two stacked landscape monitors are now drawn correctly (previously rendered as three disjoint horizontal bands with the portrait alone in the middle). Reported by bigbob. * Boxes now compute their right/bottom edges from absolute coordinates rather than independent width rounding, eliminating spurious "||" artifacts between perfectly-adjacent displays. * Lower debhelper-compat level from 13 to 11 in Build-Depends so the source package builds out of the box on Ubuntu 22.04 LTS. Reported by Creteil (bigbob) in issue #2. -- Guy-Marc APRIN <2026@gm.casa> Fri, 15 May 2026 00:20:00 +0200 lsdisplay (0.1.2-1) unstable; urgency=medium * Add SPDX license header (GPL-2.0-or-later) * Sanity check on EDID detailed timing (DTD): prefer coarse size from bytes 21-22 when DTD diagonal exceeds 2× coarse diagonal — fixes ~8" displays appearing as 59" when the DTD field contains pixels instead of millimetres (e.g. SGN L01N8A, BOE panels). Reported by Blaise on LinuxFr.org. -- Guy-Marc APRIN <2026@gm.casa> Thu, 14 May 2026 21:51:04 +0200 lsdisplay (0.1.1-1) unstable; urgency=medium * Initial release * EDID parsing for manufacturer, model, serial * ASCII layout diagram with correct proportions * Smart TV network scan (--scan) * Override file for incorrect EDID data * Refresh rate display * Color support with --no-color * --list-priority with GPU mapping * --connected-only flag -- Guy-Marc Aprin <2026@gm.casa> Sat, 03 May 2026 12:00:00 +0200 AGuyMarc-lsdisplay-1fd04f4/debian/control000066400000000000000000000013171520270207500204250ustar00rootroot00000000000000Source: lsdisplay Section: utils Priority: optional Maintainer: Guy-Marc APRIN <2026@gm.casa> Build-Depends: debhelper-compat (= 11), python3 Standards-Version: 4.6.2 Homepage: https://github.com/AGuyMarc/lsdisplay Package: lsdisplay Architecture: all Depends: python3 (>= 3.7), ${misc:Depends} Recommends: xrandr Description: List connected displays with details and ASCII layout lsdisplay lists all connected displays/monitors with manufacturer, model, serial number, resolution, position, rotation, refresh rate, and connector type. It renders an ASCII art diagram showing the physical layout. Similar to lsusb/lspci but for display devices. Supports EDID parsing, Smart TV auto-discovery, and override files. AGuyMarc-lsdisplay-1fd04f4/debian/copyright000066400000000000000000000020251520270207500207520ustar00rootroot00000000000000Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: lsdisplay Upstream-Contact: Guy-Marc APRIN <2026@gm.casa> Source: https://github.com/AGuyMarc/lsdisplay Files: * Copyright: 2026 Guy-Marc APRIN <2026@gm.casa> License: GPL-2+ License: GPL-2+ This package is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. . This package is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. . You should have received a copy of the GNU General Public License along with this program. If not, see . . On Debian systems, the complete text of the GNU General Public License version 2 can be found in "/usr/share/common-licenses/GPL-2". AGuyMarc-lsdisplay-1fd04f4/debian/install000066400000000000000000000000251520270207500204060ustar00rootroot00000000000000lsdisplay.py usr/bin AGuyMarc-lsdisplay-1fd04f4/debian/lsdisplay.docs000066400000000000000000000000121520270207500216670ustar00rootroot00000000000000README.md AGuyMarc-lsdisplay-1fd04f4/debian/lsdisplay.links000066400000000000000000000000471520270207500220670ustar00rootroot00000000000000usr/bin/lsdisplay.py usr/bin/lsdisplay AGuyMarc-lsdisplay-1fd04f4/debian/lsdisplay.manpages000066400000000000000000000000141520270207500225340ustar00rootroot00000000000000lsdisplay.1 AGuyMarc-lsdisplay-1fd04f4/debian/rules000077500000000000000000000001771520270207500201050ustar00rootroot00000000000000#!/usr/bin/make -f %: dh $@ override_dh_auto_clean: override_dh_auto_build: override_dh_auto_install: override_dh_auto_test: AGuyMarc-lsdisplay-1fd04f4/lsdisplay.1000066400000000000000000000043421520270207500176670ustar00rootroot00000000000000.\" SPDX-License-Identifier: GPL-2.0-or-later .\" Copyright (C) 2026 Guy-Marc APRIN <2026@gm.casa> .\" NB: contact email rotates yearly — 2027@gm.casa in 2027, etc. .TH LSDISPLAY 1 "May 2026" "lsdisplay 0.2.1" "User Commands" .SH NAME lsdisplay \- list connected displays with details and layout diagram .SH SYNOPSIS .B lsdisplay [\fIOPTIONS\fR] .SH DESCRIPTION .B lsdisplay lists all connected displays/monitors with manufacturer, model, serial number, resolution, position, rotation, and connector type. It also renders an ASCII art diagram showing the physical layout of the displays. .PP Display identification is performed by parsing EDID data from .IR /sys/class/drm/*/edid . Resolution and positioning information is obtained from .BR xrandr (1), with fallback to .BR kscreen-doctor (1) or .BR wlr-randr (1) on Wayland sessions. .PP .B lsdisplay is similar in spirit to .BR lscpu (1), .BR lsusb (8), and .BR lspci (8), but for display devices. .SH OPTIONS .TP .B \-\-json Output all display information as JSON. .TP .B \-\-short\fR, \fB\-s Compact output with one line per display. .TP .B \-\-no\-layout Skip the ASCII art layout diagram. .TP .B \-\-version\fR, \fB\-V Show version number and exit. .TP .B \-\-help\fR, \fB\-h Show help message and exit. .SH OUTPUT The default output consists of two sections: .TP .B CONNECTED DISPLAYS A table listing each display with output name, resolution, position, diagonal size, manufacturer, model, connector type, serial number, rotation, and primary flag. .TP .B LAYOUT An ASCII art diagram showing the relative positions and sizes of all displays with correct proportions. .SH EXAMPLES .nf .B lsdisplay .B lsdisplay \-\-json .B lsdisplay \-s .B lsdisplay \-\-json | jq '.[].manufacturer' .fi .SH FILES .TP .I /sys/class/drm/card*/edid EDID binary data for each display output. .TP .I /sys/class/drm/card*\-*/status Connection status for each output port. .SH SEE ALSO .BR xrandr (1), .BR kscreen-doctor (1), .BR lsgpu (1), .BR lspci (8), .BR lsusb (8), .BR lscpu (1), .BR edid-decode (1) .SH BUGS Report bugs at https://github.com/AGuyMarc/lsdisplay/issues .SH AUTHOR Guy-Marc APRIN <2026@gm.casa> .br NB: contact email rotates yearly \(em 2027@gm.casa in 2027, etc. .SH LICENSE GPL\-2.0. See LICENSE for the full text. AGuyMarc-lsdisplay-1fd04f4/lsdisplay.py000077500000000000000000001446531520270207500201740ustar00rootroot00000000000000#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2026 Guy-Marc APRIN <2026@gm.casa> # NB: contact email rotates yearly — 2027@gm.casa in 2027, etc. """ lsdisplay - List connected displays with details and layout diagram. Similar to lsusb, lspci, lscpu but for screens/monitors. Reads EDID from /sys/class/drm for manufacturer, model, serial number. Uses xrandr (or kscreen-doctor/wlr-randr on Wayland) for resolution and layout. Author: Guy-Marc APRIN <2026@gm.casa> NB: contact email rotates yearly — 2027@gm.casa in 2027, etc. « La perfection est atteinte non quand il n'y a plus rien à ajouter, mais quand il n'y a plus rien à retirer. » « L'essentiel est invisible pour les yeux. » — Antoine de Saint-Exupéry "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." "What is essential is invisible to the eye." Usage: lsdisplay List all displays with ASCII layout lsdisplay --json Output as JSON lsdisplay --no-layout Skip the layout diagram lsdisplay --short Compact one-line-per-display output License: GPL-2.0 """ import argparse import json as json_mod import os import re import shutil import subprocess import sys try: from dataclasses import dataclass, field, asdict except ImportError: print("Error: Python 3.7+ is required (dataclasses module missing).", file=sys.stderr) print("On AlmaLinux/RHEL 8: dnf install python39 && python3.9 lsdisplay.py", file=sys.stderr) sys.exit(1) from typing import List, Optional, Tuple __version__ = "0.2.1" def _get_version_string() -> str: """Build version string with build date from git or file modification time. Format: 0.2.1 (2026-05-18 lun 22h00m00s) """ import locale try: locale.setlocale(locale.LC_TIME, "fr_FR.UTF-8") except locale.Error: pass # Try git commit date first try: script_dir = os.path.dirname(os.path.abspath(__file__)) ts = subprocess.check_output( ["git", "log", "-1", "--format=%ct"], text=True, stderr=subprocess.DEVNULL, cwd=script_dir ).strip() from datetime import datetime dt = datetime.fromtimestamp(int(ts)) except Exception: # Fallback: file modification time try: from datetime import datetime mtime = os.path.getmtime(__file__) dt = datetime.fromtimestamp(mtime) except Exception: return __version__ day_name = dt.strftime("%a").lower().rstrip(".") date_str = dt.strftime(f"%Y-%m-%d {day_name} %Hh%Mm%Ss") return f"{__version__} ({date_str})" # PNP manufacturer IDs (subset of the official PNP ID registry) # Source: https://uefi.org/PNP_ID_List # History: https://github.com/onkoe/pnpid/blob/main/list.csv # See also: /usr/share/hwdata/pnp.ids (hwdata package) PNP_MANUFACTURERS = { "AAC": "AcerView", "ACR": "Acer", "AOC": "AOC", "AUS": "ASUS", "BNQ": "BenQ", "CMN": "Chimei Innolux", "DEL": "Dell", "ENC": "Eizo", "FUS": "Fujitsu Siemens", "GSM": "LG (GoldStar)", "HPN": "HP", "HWP": "HP", "IVM": "Iiyama", "LEN": "Lenovo", "LGD": "LG Display", "MAX": "Maxdata", "MEI": "Panasonic", "MEL": "Mitsubishi", "NEC": "NEC", "PHL": "Philips", "SAM": "Samsung", "SDC": "Samsung Display", "SNY": "Sony", "SHP": "Sharp", "TSB": "Toshiba", "VSC": "ViewSonic", "HSD": "HannStar", "BOE": "BOE", "AUO": "AU Optronics", "INL": "InnoLux", "MSI": "MSI", "GBT": "Gigabyte", } # Color support _use_color = True def _init_color(no_color_flag: bool): """Disable ANSI colors when --no-color is set or stdout is not a terminal.""" global _use_color _use_color = not no_color_flag and sys.stdout.isatty() def _c(text: str, code: str) -> str: """Wrap text in ANSI color if color is enabled.""" if _use_color: return f"\033[{code}m{text}\033[0m" return text def green(text: str) -> str: return _c(text, "32") def red(text: str) -> str: return _c(text, "31") def yellow(text: str) -> str: return _c(text, "33") @dataclass class Display: """Represents a connected display.""" name: str # xrandr output name (e.g. HDMI-A-2) drm_path: str = "" # /sys/class/drm entry width: int = 0 # resolution width in pixels height: int = 0 # resolution height in pixels x: int = 0 # position x y: int = 0 # position y rotation: str = "" # left, right, inverted, normal primary: bool = False connector: str = "" # HDMI, DisplayPort, eDP, VGA manufacturer_id: str = "" manufacturer: str = "" model: str = "" serial: str = "" width_mm: int = 0 height_mm: int = 0 diagonal_inches: float = 0.0 refresh_rate: float = 0.0 def __post_init__(self): # Infer connector type from the DRM output name prefix (e.g. "HDMI-A-2" → "HDMI") if self.name.startswith("HDMI"): self.connector = "HDMI" elif self.name.startswith("DP") or self.name.startswith("DisplayPort"): self.connector = "DisplayPort" elif self.name.startswith("eDP"): self.connector = "eDP" elif self.name.startswith("VGA"): self.connector = "VGA" elif self.name.startswith("DVI"): self.connector = "DVI" elif self.name.startswith("USB"): self.connector = "USB-C" def _load_overrides(): """Load display overrides from ~/.config/lsdisplay/overrides.json. Searches user config dir then /etc; first file found wins. Keys starting with "_" (e.g. _comment) are stripped from the result. """ home = os.environ.get("HOME", os.path.expanduser("~")) paths = [ os.path.join(home, ".config/lsdisplay/overrides.json"), os.path.expanduser("~/.config/lsdisplay/overrides.json"), "/etc/lsdisplay/overrides.json", ] for p in paths: if os.path.exists(p): try: with open(p) as f: data = json_mod.load(f) return {k: v for k, v in data.items() if not k.startswith("_")} except Exception: pass return {} _OVERRIDES = None # lazy-loaded singleton def get_overrides(): """Return cached overrides dict (loaded once on first call).""" global _OVERRIDES if _OVERRIDES is None: _OVERRIDES = _load_overrides() return _OVERRIDES def _save_overrides(overrides_dict): """Persist overrides dict to ~/.config/lsdisplay/overrides.json. Returns the path written. Re-adds the documentary _comment key. """ home = os.environ.get("HOME", os.path.expanduser("~")) config_dir = os.path.join(home, ".config", "lsdisplay") os.makedirs(config_dir, exist_ok=True) config_path = os.path.join(config_dir, "overrides.json") clean = {k: v for k, v in overrides_dict.items() if not k.startswith("_")} clean["_comment"] = ( "Display overrides keyed by MFG_ID + product_code_hex (e.g. SAM7513). " "Auto-generated or edited by lsdisplay --override-* commands." ) with open(config_path, "w") as f: json_mod.dump(clean, f, indent=2, ensure_ascii=False) return config_path def cmd_override_list(): """Print current overrides in human-readable form.""" overrides = _load_overrides() if not overrides: print("No overrides configured.") print("Default location: ~/.config/lsdisplay/overrides.json") print("Use 'lsdisplay --override-add' to add an override interactively.") return print(f"Overrides ({len(overrides)} entries):") print() for key in sorted(overrides): ov = overrides[key] print(f" {key}:") for field in ("model", "diagonal", "note", "serial"): if field in ov: val = ov[field] if field == "diagonal": val = f'{val}"' print(f" {field:<10s}: {val}") print() def cmd_override_remove(key): """Remove an override by key (e.g. SAM7513).""" overrides = _load_overrides() if key not in overrides: print(f"Override key '{key}' not found.", file=sys.stderr) keys = ", ".join(sorted(k for k in overrides if not k.startswith("_"))) print(f"Existing keys: {keys or '(none)'}", file=sys.stderr) sys.exit(1) removed = overrides.pop(key) path = _save_overrides(overrides) print(f"Removed override '{key}': {removed}") print(f"Saved to {path}") def cmd_override_set(key, model=None, diagonal=None, note=None): """Programmatically set/update an override (use with --override-model/diagonal/note).""" if not (model or diagonal or note): print("Nothing to set. Provide at least one of: --override-model, " "--override-diagonal, --override-note", file=sys.stderr) sys.exit(1) overrides = _load_overrides() entry = overrides.get(key, {}) if model: entry["model"] = model if diagonal is not None: entry["diagonal"] = float(diagonal) if note: entry["note"] = note overrides[key] = entry path = _save_overrides(overrides) print(f"Set override '{key}': {entry}") print(f"Saved to {path}") def cmd_override_add(): """Interactive wizard: pick a detected display, edit its override.""" displays = get_displays() if not displays: print("No displays detected — wizard cannot proceed.", file=sys.stderr) sys.exit(1) overrides = _load_overrides() candidates = [] for d in displays: edid = read_edid_for_output(d.name) mfg = edid.get("manufacturer_id") or d.manufacturer_id pc = edid.get("product_code") if not mfg or pc is None: continue key = f"{mfg}{pc:04X}" candidates.append({"display": d, "edid": edid, "key": key, "exists": key in overrides}) if not candidates: print("No display has readable EDID — cannot derive an override key.", file=sys.stderr) sys.exit(1) print() print("Detected displays:") print() for i, c in enumerate(candidates, 1): d = c["display"] diag = f'{d.diagonal_inches:.0f}"' if d.diagonal_inches else "?" mark = " ← already overridden" if c["exists"] else "" print(f" {i}. {d.name:<12s} {d.manufacturer or d.manufacturer_id:<14s} " f"{d.model:<20s} {diag:>4s} key={c['key']}{mark}") print() raw = input(f"Select display [1-{len(candidates)}] (q to quit): ").strip() if raw.lower() in ("", "q", "quit", "exit"): print("Cancelled.") return try: chosen = candidates[int(raw) - 1] except (ValueError, IndexError): print("Invalid choice.", file=sys.stderr) sys.exit(1) d = chosen["display"] key = chosen["key"] current = overrides.get(key, {}) cur_model = current.get("model") or d.model or "" cur_diag = current.get("diagonal") or (d.diagonal_inches if d.diagonal_inches else "") cur_note = current.get("note", "") print() print(f"Editing override for key {key}") print(f" Current model : {cur_model or '(none)'}") print(f" Current diagonal : {cur_diag or '(none)'}\"") print(f" Current note : {cur_note or '(none)'}") print() new_model = input(f"New model? [{cur_model}]: ").strip() or cur_model new_diag_s = input(f"New diagonal (inches)? [{cur_diag}]: ").strip() or str(cur_diag) if cur_diag else "" new_note = input(f"Note? [{cur_note}]: ").strip() or cur_note entry = {} if new_model: entry["model"] = new_model if new_diag_s: try: entry["diagonal"] = float(new_diag_s) except ValueError: print(f"Invalid diagonal '{new_diag_s}', ignoring.", file=sys.stderr) if new_note: entry["note"] = new_note if not entry: print("No values provided — nothing saved.") return overrides[key] = entry path = _save_overrides(overrides) print() print(f"✓ Saved to {path}") print(f" {key}: {entry}") def parse_edid(data: bytes) -> dict: """Parse EDID binary data and extract manufacturer, model, serial.""" if len(data) < 128: return {} # EDID bytes 8-9: manufacturer ID encoded as three 5-bit chars (A=1 .. Z=26) # packed into 16 bits: [0AAAAA BBBBB CCCCC]. Add 64 to get ASCII uppercase. m1, m2 = data[8], data[9] c1 = chr(((m1 >> 2) & 0x1F) + 64) # bits 14..10 of the 16-bit word c2 = chr(((m1 & 0x3) << 3 | (m2 >> 5)) + 64) # bits 9..5 c3 = chr((m2 & 0x1F) + 64) # bits 4..0 mfg_id = c1 + c2 + c3 # EDID bytes 10-11: product code (little-endian 16-bit) product_code = data[10] | (data[11] << 8) # EDID bytes 12-15: serial number (little-endian 32-bit) serial_num = data[12] | (data[13] << 8) | (data[14] << 16) | (data[15] << 24) # Parse the four 18-byte descriptor blocks starting at byte 54 width_mm = 0 height_mm = 0 name = "" serial_str = "" for i in range(4): offset = 54 + i * 18 if offset + 18 > len(data): break # Non-zero first two bytes = detailed timing descriptor (pixel clock present) if data[offset] != 0 or data[offset + 1] != 0: # Extract physical size: byte+12 = low 8 bits of width_mm, # byte+14 upper nibble = high 4 bits; same pattern for height w = data[offset + 12] | ((data[offset + 14] & 0xF0) << 4) h = data[offset + 13] | ((data[offset + 14] & 0x0F) << 8) if w > 0 and h > 0 and width_mm == 0: width_mm = w height_mm = h else: # Display descriptor: byte+3 is the tag type tag = data[offset + 3] raw = data[offset + 5:offset + 18] # 13-byte ASCII payload text = raw.decode("ascii", errors="replace").strip().rstrip("\n").rstrip("\r") if tag == 0xFC: # Monitor name descriptor name = text elif tag == 0xFF: # Monitor serial string descriptor serial_str = text # Sanity check: some cheap displays put pixel dimensions in the DTD mm fields # (e.g. 800x1280 pixels reported as 800x1280 mm = 59" instead of 8"). # Also catch unreasonably large values (> 2000mm = ~80"). # In these cases, prefer the coarse size from bytes 21-22. coarse_w = data[21] * 10 # cm -> mm coarse_h = data[22] * 10 if width_mm > 0 and coarse_w > 0 and coarse_h > 0: import math dtd_diag = math.sqrt(width_mm**2 + height_mm**2) / 25.4 coarse_diag = math.sqrt(coarse_w**2 + coarse_h**2) / 25.4 # If DTD diagonal is more than 2x the coarse diagonal, DTD is bogus if dtd_diag > 2 * coarse_diag and coarse_diag > 0: width_mm = coarse_w height_mm = coarse_h # Fallback: bytes 21-22 give coarse physical size in centimeters if width_mm == 0: width_mm = coarse_w height_mm = coarse_h result = { "manufacturer_id": mfg_id, "manufacturer": PNP_MANUFACTURERS.get(mfg_id, mfg_id), "model": name, "serial": serial_str if serial_str else str(serial_num) if serial_num else "", "product_code": product_code, "width_mm": width_mm, "height_mm": height_mm, } # Apply overrides keyed by "MFG_ID + product_code_hex" (e.g. "SAM0A3E") key = f"{mfg_id}{product_code:04X}" overrides = get_overrides() if key in overrides: ov = overrides[key] if "model" in ov: result["model"] = ov["model"] if "diagonal" in ov: result["diagonal_override"] = ov["diagonal"] if "serial" in ov: result["serial"] = ov["serial"] return result def _build_connector_id_map() -> dict: """Build a mapping of connector_id -> sysfs EDID path. Each /sys/class/drm/card*-*/connector_id file contains an integer that matches the CONNECTOR_ID property exposed by xrandr (modesetting/i915/amdgpu). This allows us to link xrandr output names (e.g. DP-1-3) to the correct sysfs entry (e.g. card0-DP-5) even when names differ (MST hubs, evdi/DisplayLink). """ drm_dir = "/sys/class/drm" cid_map = {} if not os.path.isdir(drm_dir): return cid_map for entry in os.listdir(drm_dir): cid_path = os.path.join(drm_dir, entry, "connector_id") if os.path.exists(cid_path): try: with open(cid_path) as f: cid = f.read().strip() edid_path = os.path.join(drm_dir, entry, "edid") if os.path.exists(edid_path): cid_map[cid] = edid_path except (IOError, PermissionError): pass return cid_map _CONNECTOR_ID_MAP = None # lazy singleton def _get_connector_id_map() -> dict: global _CONNECTOR_ID_MAP if _CONNECTOR_ID_MAP is None: _CONNECTOR_ID_MAP = _build_connector_id_map() return _CONNECTOR_ID_MAP def _get_xrandr_connector_ids() -> dict: """Parse xrandr --properties to extract CONNECTOR_ID per output. Returns a dict: {"DP-1-3": "42", "eDP-1": "51", ...} Only available with modesetting/i915/amdgpu DDX; NVIDIA proprietary driver does not expose this property. """ try: output = subprocess.check_output( ["xrandr", "--properties"], text=True, stderr=subprocess.DEVNULL ) except (subprocess.CalledProcessError, FileNotFoundError): return {} result = {} current_output = None for line in output.split("\n"): # Output header: "DP-1-3 connected primary 1920x1080+0+0 ..." m = re.match(r"^(\S+)\s+(?:connected|disconnected)", line) if m: current_output = m.group(1) # Property line: "\tCONNECTOR_ID: 42" elif current_output and "CONNECTOR_ID" in line: cm = re.search(r"CONNECTOR_ID:\s*(\d+)", line) if cm: result[current_output] = cm.group(1) return result _XRANDR_CONNECTOR_IDS = None # lazy singleton def _get_xrandr_connector_ids_cached() -> dict: global _XRANDR_CONNECTOR_IDS if _XRANDR_CONNECTOR_IDS is None: _XRANDR_CONNECTOR_IDS = _get_xrandr_connector_ids() return _XRANDR_CONNECTOR_IDS def _parse_xrandr_edid_blocks() -> dict: """Parse EDID hex blocks from xrandr --verbose as a last-resort fallback. Returns a dict: {"DP-1-3": bytes, "eDP-1": bytes, ...} Some drivers (Intel/AMD) expose EDID through xrandr properties. """ try: output = subprocess.check_output( ["xrandr", "--verbose"], text=True, stderr=subprocess.DEVNULL ) except (subprocess.CalledProcessError, FileNotFoundError): return {} result = {} current_output = None edid_lines = [] in_edid = False for line in output.split("\n"): m = re.match(r"^(\S+)\s+(?:connected|disconnected)", line) if m: # Save previous EDID block if any if current_output and edid_lines: hex_str = "".join(edid_lines) try: result[current_output] = bytes.fromhex(hex_str) except ValueError: pass current_output = m.group(1) edid_lines = [] in_edid = False elif "EDID:" in line: in_edid = True edid_lines = [] elif in_edid: stripped = line.strip() if re.match(r"^[0-9a-fA-F]+$", stripped) and len(stripped) == 32: edid_lines.append(stripped) else: in_edid = False # Don't forget the last output if current_output and edid_lines: hex_str = "".join(edid_lines) try: result[current_output] = bytes.fromhex(hex_str) except ValueError: pass return result _XRANDR_EDID_BLOCKS = None # lazy singleton def _get_xrandr_edid_blocks() -> dict: global _XRANDR_EDID_BLOCKS if _XRANDR_EDID_BLOCKS is None: _XRANDR_EDID_BLOCKS = _parse_xrandr_edid_blocks() return _XRANDR_EDID_BLOCKS def read_edid_for_output(output_name: str) -> dict: """Read and parse EDID for a display output, trying multiple strategies: 1. CONNECTOR_ID: match xrandr CONNECTOR_ID property to sysfs connector_id 2. Exact name match: look for /sys/class/drm/card*-/edid 3. xrandr --verbose: parse EDID hex blocks from xrandr properties """ drm_dir = "/sys/class/drm" # Strategy 1: CONNECTOR_ID mapping (works for MST hubs, evdi/DisplayLink) xrandr_cids = _get_xrandr_connector_ids_cached() if output_name in xrandr_cids: cid_map = _get_connector_id_map() cid = xrandr_cids[output_name] if cid in cid_map: try: with open(cid_map[cid], "rb") as f: data = f.read() if len(data) >= 128: return parse_edid(data) except (IOError, PermissionError): pass # Strategy 2: exact sysfs name match (e.g. "card0-HDMI-A-2" ends with "-HDMI-A-2") if os.path.isdir(drm_dir): suffix = "-" + output_name for entry in os.listdir(drm_dir): if entry.endswith(suffix): edid_path = os.path.join(drm_dir, entry, "edid") if os.path.exists(edid_path): try: with open(edid_path, "rb") as f: data = f.read() if len(data) >= 128: return parse_edid(data) except (IOError, PermissionError): pass # Strategy 3: EDID from xrandr --verbose (last resort) edid_blocks = _get_xrandr_edid_blocks() if output_name in edid_blocks: data = edid_blocks[output_name] if len(data) >= 128: return parse_edid(data) return {} def get_displays_xrandr() -> List[Display]: """Get display information using xrandr.""" try: output = subprocess.check_output(["xrandr"], text=True, stderr=subprocess.DEVNULL) except (subprocess.CalledProcessError, FileNotFoundError): return [] displays = [] # Parse xrandr "connected" lines, e.g.: # HDMI-A-2 connected primary 2560x1440+0+0 normal (…) 597mm x 336mm pattern = re.compile( r"^(\S+) connected\s*(primary)?\s*(\d+)x(\d+)\+(\d+)\+(\d+)\s*" r"(left|right|inverted|normal)?\s*" r"(?:\(.*?\))?\s*" r"(?:.*?(\d+)mm x (\d+)mm)?" ) lines = output.split("\n") for idx, line in enumerate(lines): m = pattern.match(line) if m: name = m.group(1) d = Display(name=name) d.primary = m.group(2) is not None d.width = int(m.group(3)) d.height = int(m.group(4)) d.x = int(m.group(5)) d.y = int(m.group(6)) d.rotation = m.group(7) or "normal" # Scan indented mode lines below this output for the active refresh rate # (marked with * by xrandr, e.g. "59.95*+") for mode_idx in range(idx + 1, len(lines)): mode_line = lines[mode_idx] # Stop at the next output line if mode_line and not mode_line.startswith(" "): break # Look for refresh rate with * (current mode) rate_match = re.search(r'(\d+\.\d+)\*', mode_line) if rate_match: d.refresh_rate = float(rate_match.group(1)) break if m.group(8) and m.group(9): d.width_mm = int(m.group(8)) d.height_mm = int(m.group(9)) import math diag_mm = math.sqrt(d.width_mm ** 2 + d.height_mm ** 2) d.diagonal_inches = round(diag_mm / 25.4) # Enrich with EDID data (manufacturer, model, serial, precise dimensions) edid = read_edid_for_output(name) if edid: d.manufacturer_id = edid.get("manufacturer_id", "") d.manufacturer = edid.get("manufacturer", "") d.model = edid.get("model", "") d.serial = edid.get("serial", "") # Prefer override diagonal (from --scan), then EDID mm, then xrandr mm if "diagonal_override" in edid: d.diagonal_inches = edid["diagonal_override"] else: edid_w = edid.get("width_mm", 0) edid_h = edid.get("height_mm", 0) if edid_w > 0 and edid_h > 0: d.width_mm = edid_w d.height_mm = edid_h import math d.diagonal_inches = round(math.sqrt(edid_w**2 + edid_h**2) / 25.4) displays.append(d) return displays def get_displays_wlr() -> List[Display]: """Get display info using wlr-randr (wlroots-based compositors: Sway, Hyprland, etc.).""" try: output = subprocess.check_output(["wlr-randr"], text=True, stderr=subprocess.DEVNULL) except (subprocess.CalledProcessError, FileNotFoundError): return [] displays = [] current = None for line in output.split("\n"): # Non-indented line starts a new output block: e.g. 'DP-1 "Manufacturer Model"' m = re.match(r"^(\S+)\s+", line) if m and not line.startswith(" "): if current: displays.append(current) current = Display(name=m.group(1)) if current and line.startswith(" "): stripped = line.strip() # Position: 1880,0 pm = re.match(r"Position:\s*(\d+),(\d+)", stripped) if pm: current.x = int(pm.group(1)) current.y = int(pm.group(2)) # Physical size: 597x336 mm sm = re.match(r"Physical size:\s*(\d+)x(\d+)\s*mm", stripped) if sm: current.width_mm = int(sm.group(1)) current.height_mm = int(sm.group(2)) import math current.diagonal_inches = round( math.sqrt(current.width_mm**2 + current.height_mm**2) / 25.4 ) # wlr-randr uses degrees; map to xrandr-style rotation names tm = re.match(r"Transform:\s*(\S+)", stripped) if tm: t = tm.group(1) rot_map = {"normal": "normal", "90": "left", "180": "inverted", "270": "right"} current.rotation = rot_map.get(t, "normal") # Mode line with "(current)" suffix is the active mode mm = re.match(r"(\d+)x(\d+)\s+px,\s*([\d.]+)\s*Hz\s*\(.*current", stripped) if mm: current.width = int(mm.group(1)) current.height = int(mm.group(2)) current.refresh_rate = float(mm.group(3)) # Drop disabled outputs entirely em = re.match(r"Enabled:\s*no", stripped) if em: current = None if current: displays.append(current) # Enrich with EDID for d in displays: edid = read_edid_for_output(d.name) if edid: d.manufacturer_id = edid.get("manufacturer_id", "") d.manufacturer = edid.get("manufacturer", "") d.model = edid.get("model", "") d.serial = edid.get("serial", "") if "diagonal_override" in edid: d.diagonal_inches = edid["diagonal_override"] else: edid_w = edid.get("width_mm", 0) edid_h = edid.get("height_mm", 0) if edid_w > 0 and edid_h > 0: d.width_mm = edid_w d.height_mm = edid_h import math d.diagonal_inches = round(math.sqrt(edid_w**2 + edid_h**2) / 25.4) return displays def get_displays_kscreen() -> List[Display]: """Fallback: get display info using kscreen-doctor (KDE Wayland).""" try: output = subprocess.check_output( ["kscreen-doctor", "--outputs"], text=True, stderr=subprocess.DEVNULL ) except (subprocess.CalledProcessError, FileNotFoundError): return [] displays = [] current = None for line in output.split("\n"): m = re.match(r"Output:\s*\d+\s+(\S+)", line) if m: if current: displays.append(current) current = Display(name=m.group(1)) if current: if "enabled" in line: pass # connected and enabled if "Geometry:" in line: gm = re.search(r"(\d+),(\d+)\s+(\d+)x(\d+)", line) if gm: current.x = int(gm.group(1)) current.y = int(gm.group(2)) current.width = int(gm.group(3)) current.height = int(gm.group(4)) if "Rotation:" in line: # kscreen-doctor uses bitmask values: 1=normal, 2=left, 4=inverted, 8=right rm = re.search(r"Rotation:\s*(\d+)", line) if rm: rot_map = {0: "normal", 1: "normal", 2: "left", 4: "inverted", 8: "right"} current.rotation = rot_map.get(int(rm.group(1)), "normal") if "primary" in line.lower(): current.primary = True if current: displays.append(current) # Enrich with EDID for d in displays: edid = read_edid_for_output(d.name) if edid: d.manufacturer_id = edid.get("manufacturer_id", "") d.manufacturer = edid.get("manufacturer", "") d.model = edid.get("model", "") d.serial = edid.get("serial", "") return displays def get_displays() -> List[Display]: """Detect displays via the best available backend. Strategy: if WAYLAND_DISPLAY is set, try wlr-randr (wlroots) then kscreen-doctor (KDE Plasma). Fall back to xrandr (works on X11 and XWayland). """ wayland = os.environ.get("WAYLAND_DISPLAY") if wayland: displays = get_displays_wlr() if displays: return displays displays = get_displays_kscreen() if displays: return displays displays = get_displays_xrandr() return displays def get_gpu_mapping() -> dict: """Map each DRM card to its GPU name (via lspci) and list of output ports. Returns: {"card0": {"name": "NVIDIA ...", "outputs": [{"port": "HDMI-A-2", "connected": True}, ...]}} """ gpus = {} drm_dir = "/sys/class/drm" if not os.path.isdir(drm_dir): return gpus for entry in sorted(os.listdir(drm_dir)): m = re.match(r"^(card\d+)$", entry) if m: card = m.group(1) card_path = os.path.join(drm_dir, card) device_link = os.path.join(card_path, "device") # Resolve the PCI address from the device symlink, then query lspci gpu_name = "" try: pci_addr = os.path.basename(os.readlink(device_link)) lspci_out = subprocess.check_output( ["lspci", "-s", pci_addr], text=True, stderr=subprocess.DEVNULL ).strip() gpu_name = re.sub(r"^[0-9a-f:.]+\s+\S+\s+\S+\s+controller:\s*", "", lspci_out, flags=re.IGNORECASE) except (OSError, subprocess.CalledProcessError): pass # Enumerate connector entries for this card (e.g. "card0-HDMI-A-2") outputs = [] for sub in sorted(os.listdir(drm_dir)): if sub.startswith(card + "-"): port = sub[len(card) + 1:] # strip "card0-" prefix status_path = os.path.join(drm_dir, sub, "status") edid_path = os.path.join(drm_dir, sub, "edid") # Check connection via status file, fall back to non-empty EDID connected = False try: with open(status_path) as f: connected = f.read().strip() == "connected" except (IOError, PermissionError): pass if not connected: try: connected = os.path.getsize(edid_path) > 0 except OSError: pass outputs.append({"port": port, "connected": connected}) gpus[card] = {"name": gpu_name, "outputs": outputs} return gpus def print_gpus(gpus: dict, displays: List[Display]): """Print GPU information with output mapping.""" print("GRAPHICS CARDS") print("=" * 14) print() for card in sorted(gpus.keys()): info = gpus[card] print(f" {card}: {yellow(info['name'])}") for out in info["outputs"]: port = out["port"] if out["connected"]: # Find matching display match = "" for d in displays: if d.name == port: mfg = d.manufacturer or d.manufacturer_id diag = f' {d.diagonal_inches:.0f}"' if d.diagonal_inches else "" match = f" ← {mfg} {d.model}{diag}" break print(f" └─ {port}: {green('connected')}{match}") else: print(f" └─ {port}: {red('-')}") print() def print_table(displays: List[Display]): """Print displays in a formatted table.""" print("CONNECTED DISPLAYS") print("=" * 18) print() for d in displays: primary = green(" [PRIMARY]") if d.primary else "" rot = f" rot={d.rotation}" if d.rotation and d.rotation != "normal" else "" diag = f'{d.diagonal_inches:.0f}"' if d.diagonal_inches else "" hz = f"{d.refresh_rate:.0f}Hz" if d.refresh_rate else "" # Avoid "Samsung SAMSUNG" redundancy: suppress model if it matches manufacturer model = d.model if d.model.upper() != d.manufacturer.upper() else "" mfg_model = f"{d.manufacturer} {model}".strip() serial = d.serial if d.serial else "" pos = f"{d.width}x{d.height}+{d.x}+{d.y}" padded_name = d.name.ljust(12) name_col = green(padded_name) if d.primary else padded_name printf_fmt = f" {name_col} {pos:<22s} {diag:>4s} {hz:>5s} {mfg_model:<20s} {d.connector:<12s} S/N:{serial:<15s}{rot}{primary}" print(printf_fmt) print() n = len(displays) print(f"Total: {n} display{'s' if n != 1 else ''} connected") def render_layout(displays: List[Display], term_cols: int = None) -> List[str]: """Render the layout diagram as a list of lines (no header, no trailing blank). Each display is painted onto a 2D character canvas at its scaled (x, y) position. This correctly handles partial vertical overlaps — e.g. a portrait monitor on the left whose y-range straddles two stacked landscape monitors on the right — which a row-banding approach cannot represent. Larger displays are drawn first so smaller boxes paint on top: their corners stay visible at shared edges instead of being erased by a neighbour's continuous edge. """ if not displays: return [] char_aspect = 2.0 # terminal chars are ~2x taller than wide min_x = min(d.x for d in displays) min_y = min(d.y for d in displays) max_x = max(d.x + d.width for d in displays) max_y = max(d.y + d.height for d in displays) span_x = max(1, max_x - min_x) span_y = max(1, max_y - min_y) if term_cols is None: term_cols = shutil.get_terminal_size().columns target_cols = min(70, max(10, term_cols - 4)) px_per_col = span_x / target_cols px_per_row = px_per_col * char_aspect total_cols = target_cols + 1 # +1 for the rightmost box's right edge total_rows = max(3, int(round(span_y / px_per_row)) + 1) canvas = [[" "] * total_cols for _ in range(total_rows)] def place(r, c, ch): if 0 <= r < total_rows and 0 <= c < total_cols: canvas[r][c] = ch # Largest first so smaller boxes overwrite at shared edges (keeps their # corners visible). Primary as tie-breaker so its label wins on identical # geometries. def order_key(d): return (-(d.width * d.height), 0 if d.primary else 1) for d in sorted(displays, key=order_key): label = d.name if d.primary and d.manufacturer_id != "SAM": label = d.name + "*" # Compute box edges from absolute coordinates so adjacent displays # always share a column / row (avoids 1-cell gaps from independent # rounding of width/height). col_x = int(round((d.x - min_x) / px_per_col)) col_xr = int(round((d.x + d.width - min_x) / px_per_col)) row_y = int(round((d.y - min_y) / px_per_row)) row_yb = int(round((d.y + d.height - min_y) / px_per_row)) box_w = max(len(label) + 2, col_xr - col_x) box_h = max(3, row_yb - row_y) # Clamp to canvas if col_x + box_w >= total_cols: box_w = max(3, total_cols - col_x - 1) if row_y + box_h >= total_rows: box_h = max(3, total_rows - row_y - 1) # Top and bottom edges for c in range(col_x, col_x + box_w + 1): corner = (c == col_x) or (c == col_x + box_w) place(row_y, c, "+" if corner else "-") place(row_y + box_h, c, "+" if corner else "-") # Left and right edges for r in range(row_y + 1, row_y + box_h): place(r, col_x, "|") place(r, col_x + box_w, "|") # Centred label on the middle interior row if box_h >= 3 and box_w >= 3: mid_r = row_y + box_h // 2 label_text = label[: box_w - 1] start_c = col_x + 1 + (box_w - 1 - len(label_text)) // 2 for i, ch in enumerate(label_text): place(mid_r, start_c + i, ch) return ["".join(row).rstrip() for row in canvas] def draw_layout(displays: List[Display]): """Print the LAYOUT section to stdout.""" if not displays: return print() print("LAYOUT") print("=" * 6) print() for line in render_layout(displays): print(" " + line) print() def list_priority(displays, connected_only=False): """List displays sorted by priority: primary first, then left-to-right, top-to-bottom.""" primary = [d for d in displays if d.primary] others = sorted([d for d in displays if not d.primary], key=lambda d: (d.y, d.x)) ordered = primary + others connected_names = {d.name for d in displays} print("DISPLAY PRIORITY ORDER") print("=" * 21) print() for i, d in enumerate(ordered, 1): mfg = d.manufacturer or d.manufacturer_id model = d.model if d.model.upper() != mfg.upper() else "" diag = f'{d.diagonal_inches:.0f}"' if d.diagonal_inches else "" primary_tag = green(" <- PRIMARY") if d.primary else "" connector = d.connector gpu = "" # Find which GPU this output belongs to for entry in sorted(os.listdir("/sys/class/drm")): if d.name in entry and entry.startswith("card"): gpu = entry.split("-")[0] break padded_name = d.name.ljust(12) name_col = green(padded_name) if d.primary else padded_name print(f" {i}. {name_col} {d.width}x{d.height:<6} {diag:>4s} {mfg} {model} [{connector}/{gpu}]{primary_tag}") # Show disconnected outputs if not filtered if not connected_only: gpus = get_gpu_mapping() disconnected = [] for card, info in gpus.items(): for out in info["outputs"]: if not out["connected"] and out["port"] not in connected_names: disconnected.append((out["port"], card)) if disconnected: print() for port, card in disconnected: print(f" - {red(port.ljust(12))} {red('disconnected'):20s} [{card}]") print() print() def scan_network(subnet=None): """Scan the local /24 network for Samsung SmartTVs on port 8001. For each TV found, queries the Samsung REST API for model/resolution/MAC, then matches it to a connected Samsung EDID display by closest resolution. Results are saved to overrides.json so future runs show TV model names. """ import socket import urllib.request import ipaddress # Auto-detect subnet from the default route interface's IP if not subnet: try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) local_ip = s.getsockname()[0] s.close() parts = local_ip.rsplit(".", 1) subnet = parts[0] + ".0/24" except Exception: subnet = "192.168.1.0/24" print(f"Scanning {subnet} for Smart TVs (port 8001)...") print() # Get current EDID displays for matching displays = get_displays() samsung_displays = [d for d in displays if d.manufacturer_id == "SAM"] # Probe each host on port 8001 (Samsung SmartTV WebSocket/REST API port) network = ipaddress.IPv4Network(subnet, strict=False) found_tvs = [] for ip in network.hosts(): ip_str = str(ip) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(0.3) # fast fail for non-responsive hosts result = sock.connect_ex((ip_str, 8001)) sock.close() if result == 0: # Port open -- query Samsung REST API at /api/v2/ try: url = f"http://{ip_str}:8001/api/v2/" req = urllib.request.Request(url) with urllib.request.urlopen(req, timeout=2) as resp: data = json_mod.loads(resp.read().decode("utf-8")) dev = data.get("device", {}) if dev.get("type") == "Samsung SmartTV": tv_info = { "ip": ip_str, "model": dev.get("modelName", ""), "name": dev.get("name", "").replace(""", '"'), "resolution": dev.get("resolution", ""), "mac": dev.get("wifiMac", dev.get("networkMac", "")), "uuid": dev.get("id", ""), "os": dev.get("OS", ""), "firmware": dev.get("firmwareVersion", ""), } found_tvs.append(tv_info) print(f" Found: {tv_info['name']} ({tv_info['model']})") print(f" IP: {ip_str} MAC: {tv_info['mac']}") print(f" Resolution: {tv_info['resolution']}") print() except Exception: pass if not found_tvs: print("No Samsung Smart TVs found on the network.") return # Match discovered TVs to connected Samsung EDID displays by resolution proximity import re overrides = get_overrides() updated = False matched_displays = set() # Build lookup of connected Samsung displays with their EDID product codes sam_edid_info = [] for d in samsung_displays: edid = read_edid_for_output(d.name) if edid and edid.get("manufacturer_id") == "SAM": sam_edid_info.append({ "display": d, "key": f"SAM{edid['product_code']:04X}", "res": f"{d.width}x{d.height}", "edid": edid, }) for tv in found_tvs: tv_res = tv["resolution"] # e.g. "7680x4320" tv_w, tv_h = 0, 0 rm = re.match(r"(\d+)x(\d+)", tv_res) if rm: tv_w, tv_h = int(rm.group(1)), int(rm.group(2)) # Extract diagonal size from the TV's friendly name (e.g. '65" Neo QLED 8K' -> 65) diag = 0 dm = re.search(r'(\d{2,3})"?\s', tv["name"]) if dm: diag = int(dm.group(1)) # Greedy match: pick the Samsung display with the closest max dimension best_match = None best_distance = float("inf") for info in sam_edid_info: if info["key"] in matched_displays: continue d = info["display"] d_max = max(d.width, d.height) tv_max = max(tv_w, tv_h) distance = abs(d_max - tv_max) if distance < best_distance: best_distance = distance best_match = info if best_match: key = best_match["key"] matched_displays.add(key) d = best_match["display"] overrides[key] = { "model": tv["model"], "serial": tv["mac"], "note": tv["name"], } if diag: overrides[key]["diagonal"] = diag updated = True match_type = "exact" if best_distance == 0 else f"closest (Δ{best_distance}px)" print(f" Matched ({match_type}): {d.name} ({d.width}x{d.height}) ↔ {tv['name']} ({tv_res})") print(f" Override: {key} → {tv['model']} diag={diag}\" MAC={tv['mac']}") print() else: print(f" No matching display for: {tv['name']} ({tv_res})") print() if updated: # Persist overrides to user config dir; also copy to /etc if writable overrides["_comment"] = "Auto-generated by lsdisplay --scan. Key = MFG_ID + product_code_hex" home = os.environ.get("HOME", os.path.expanduser("~")) config_dir = os.path.join(home, ".config", "lsdisplay") os.makedirs(config_dir, exist_ok=True) config_path = os.path.join(config_dir, "overrides.json") with open(config_path, "w") as f: json_mod.dump(overrides, f, indent=2, ensure_ascii=False) print(f"Overrides saved to {config_path}") try: etc_dir = "/etc/lsdisplay" os.makedirs(etc_dir, exist_ok=True) import shutil as sh sh.copy2(config_path, os.path.join(etc_dir, "overrides.json")) print(f"Also copied to {etc_dir}/overrides.json") except PermissionError: pass else: print("No new overrides to save (all TVs already configured).") print(f"\nSummary: {len(found_tvs)} TV(s) found, {len(samsung_displays)} Samsung display(s) connected") def main(): parser = argparse.ArgumentParser( prog="lsdisplay", description="List connected displays with manufacturer, model, serial number, and ASCII layout diagram.", epilog="""examples: lsdisplay list all displays with layout diagram lsdisplay --short compact one-line-per-display output lsdisplay --json JSON output for scripting lsdisplay --no-layout skip the ASCII art diagram lsdisplay --scan scan network for Smart TVs and auto-configure lsdisplay --list-priority show display priority order lsdisplay --override-list show current display overrides lsdisplay --override-add interactive wizard to add an override lsdisplay --override-set SAM7513 --override-model QN65QN900B --override-diagonal 65 --override-note Salon lsdisplay --override-remove SAM7513 lsdisplay --json | jq '.[].manufacturer' source: https://github.com/AGuyMarc/lsdisplay license: GPL-2.0""", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--json", action="store_true", help="output as JSON (for scripting)" ) parser.add_argument( "--no-layout", action="store_true", help="skip the ASCII art layout diagram" ) parser.add_argument( "--short", "-s", action="store_true", help="compact one-line-per-display output" ) parser.add_argument( "--scan", nargs="?", const="auto", default=None, metavar="SUBNET", help="scan network for Smart TVs (default: auto-detect subnet)" ) parser.add_argument( "--list-priority", action="store_true", help="show display priority order with GPU mapping" ) parser.add_argument( "--connected-only", action="store_true", help="only show connected displays (hide disconnected outputs)" ) parser.add_argument( "--no-color", action="store_true", help="disable colored output" ) parser.add_argument( "--override-list", action="store_true", help="list current display overrides" ) parser.add_argument( "--override-add", action="store_true", help="interactively add an override for a detected display (wizard)" ) parser.add_argument( "--override-set", metavar="KEY", help="programmatically set an override (use with --override-model/diagonal/note)" ) parser.add_argument( "--override-remove", metavar="KEY", help="remove an override by key (e.g. SAM7513)" ) parser.add_argument( "--override-model", help="model name (with --override-set)" ) parser.add_argument( "--override-diagonal", type=float, help="diagonal in inches (with --override-set)" ) parser.add_argument( "--override-note", help="note/description (with --override-set)" ) parser.add_argument( "--version", "-V", action="version", version=f"%(prog)s {_get_version_string()}" ) args = parser.parse_args() _init_color(args.no_color) if args.override_list: cmd_override_list() return if args.override_add: cmd_override_add() return if args.override_set: cmd_override_set(args.override_set, args.override_model, args.override_diagonal, args.override_note) return if args.override_remove: cmd_override_remove(args.override_remove) return if args.scan: subnet = None if args.scan == "auto" else args.scan scan_network(subnet) return displays = get_displays() if not displays: print("No displays found.", file=sys.stderr) print("Ensure xrandr, kscreen-doctor, or wlr-randr is available.", file=sys.stderr) sys.exit(1) if args.list_priority: list_priority(displays, connected_only=args.connected_only) return if args.json: data = [asdict(d) for d in displays] print(json_mod.dumps(data, indent=2, ensure_ascii=False)) return if args.short: for d in displays: mfg = d.manufacturer or d.manufacturer_id diag = f'{d.diagonal_inches:.0f}"' if d.diagonal_inches else "" p = "*" if d.primary else " " model = d.model if d.model.upper() != mfg.upper() else "" hz = f"@{d.refresh_rate:.0f}Hz" if d.refresh_rate else "" print(f"{p} {d.name:<12s} {d.width}x{d.height}{hz} {diag:>4s} {mfg} {model} [{d.connector}]") return print_table(displays) if not args.no_layout: draw_layout(displays) if __name__ == "__main__": main() AGuyMarc-lsdisplay-1fd04f4/overrides.json.example000066400000000000000000000004361520270207500221300ustar00rootroot00000000000000{ "_comment": "Override EDID info for displays with incorrect data. Key = MFG_ID + product_code_hex", "SAM7513": {"model": "QN65QN900B", "diagonal": 65, "note": "Samsung Neo QLED 8K 65 Salon"}, "SAM71A5": {"model": "QE32Q50A", "diagonal": 32, "note": "Samsung Q50AE 32 Loggia"} } AGuyMarc-lsdisplay-1fd04f4/setup.py000066400000000000000000000020501520270207500173050ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2026 Guy-Marc APRIN <2026@gm.casa> # NB: contact email rotates yearly — 2027@gm.casa in 2027, etc. from setuptools import setup setup( name="lsdisplay", version="0.2.1", description="List connected displays — like lsusb/lspci but for screens", long_description=open("README.md").read(), long_description_content_type="text/markdown", author="Guy-Marc APRIN", author_email="2026@gm.casa", license="GPL-2.0", py_modules=["lsdisplay"], entry_points={ "console_scripts": [ "lsdisplay=lsdisplay:main", ], }, python_requires=">=3.7", classifiers=[ "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: System Administrators", "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", "Topic :: System :: Hardware", "Topic :: Utilities", ], ) AGuyMarc-lsdisplay-1fd04f4/tests/000077500000000000000000000000001520270207500167405ustar00rootroot00000000000000AGuyMarc-lsdisplay-1fd04f4/tests/test_lsdisplay.py000066400000000000000000000207401520270207500223600ustar00rootroot00000000000000#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2026 Guy-Marc APRIN <2026@gm.casa> # NB: contact email rotates yearly — 2027@gm.casa in 2027, etc. """Tests unitaires pour lsdisplay.""" import json import os import subprocess import sys import unittest # Ajouter le dossier parent au path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from lsdisplay import ( parse_edid, PNP_MANUFACTURERS, Display, _load_overrides, get_overrides, render_layout, ) class TestPNPManufacturers(unittest.TestCase): def test_known_manufacturers(self): self.assertEqual(PNP_MANUFACTURERS["SAM"], "Samsung") self.assertEqual(PNP_MANUFACTURERS["IVM"], "Iiyama") self.assertEqual(PNP_MANUFACTURERS["DEL"], "Dell") def test_manufacturer_count(self): self.assertGreater(len(PNP_MANUFACTURERS), 20) class TestParseEdid(unittest.TestCase): def test_empty_data(self): self.assertEqual(parse_edid(b""), {}) def test_short_data(self): self.assertEqual(parse_edid(b"\x00" * 50), {}) def test_minimal_edid(self): # Build a minimal 128-byte EDID edid = bytearray(128) # Header edid[0:8] = b"\x00\xff\xff\xff\xff\xff\xff\x00" # Manufacturer "SAM" = S=19, A=1, M=13 # S=19: bits 6-2 of byte 8 = 10011 # A=1: bits 1-0 of byte 8 + bits 7-5 of byte 9 = 00 001 # M=13: bits 4-0 of byte 9 = 01101 edid[8] = 0b01001100 # SAM edid[9] = 0b00101101 # Product code edid[10] = 0x13 edid[11] = 0x75 # Physical size 142cm x 80cm edid[21] = 142 edid[22] = 80 result = parse_edid(bytes(edid)) self.assertEqual(result["manufacturer_id"], "SAM") self.assertEqual(result["manufacturer"], "Samsung") self.assertEqual(result["product_code"], 0x7513) class TestDisplay(unittest.TestCase): def test_connector_detection_hdmi(self): d = Display(name="HDMI-A-2") self.assertEqual(d.connector, "HDMI") def test_connector_detection_dp(self): d = Display(name="DP-4") self.assertEqual(d.connector, "DisplayPort") def test_connector_detection_edp(self): d = Display(name="eDP-1") self.assertEqual(d.connector, "eDP") def test_connector_detection_vga(self): d = Display(name="VGA-1") self.assertEqual(d.connector, "VGA") class TestOverrides(unittest.TestCase): def test_load_nonexistent(self): result = _load_overrides() # May or may not find a file, but should not crash self.assertIsInstance(result, dict) def test_comment_filtered(self): overrides = get_overrides() for key in overrides: self.assertFalse(key.startswith("_"), f"Key {key} should be filtered") class TestRenderLayout(unittest.TestCase): """Tests for the 2D-canvas layout renderer (regression for v0.1.3).""" def _label_row(self, lines, label): for i, ln in enumerate(lines): if label in ln: return i raise AssertionError(f"label {label!r} not found in:\n" + "\n".join(lines)) def _next_horiz_edge(self, lines, start_row): """First row at index >= start_row that looks like a horizontal box edge: contains '+' corners and many '-' segments.""" for i in range(start_row, len(lines)): if "+" in lines[i] and lines[i].count("-") > 10: return i return None def test_empty_returns_empty(self): self.assertEqual(render_layout([]), []) def test_single_display_renders_box(self): d = Display(name="DP-1", width=2560, height=1440, x=0, y=0, primary=True) lines = render_layout([d], term_cols=80) self.assertTrue(any("DP-1" in ln for ln in lines)) self.assertTrue(lines[0].startswith("+") and lines[0].endswith("+")) self.assertTrue(lines[-1].startswith("+") and lines[-1].endswith("+")) def test_portrait_straddles_two_stacked_landscapes(self): """Bigbob case: HDMI-A-1 portrait on the left must vertically overlap BOTH stacked Samsung monitors (DP-2 top, DP-1 bottom). Regression for the row-banding bug fixed in v0.1.3 — the old algo rendered three disjoint horizontal bands with HDMI alone in the middle.""" ds = [ Display(name="HDMI-A-1", width=1080, height=1920, x=0, y=733), Display(name="DP-1", width=5120, height=1440, x=1080, y=1440, primary=True, manufacturer_id="SAM"), Display(name="DP-2", width=5120, height=1440, x=1080, y=0, manufacturer_id="SAM"), ] lines = render_layout(ds, term_cols=80) r_d2 = self._label_row(lines, "DP-2") r_h = self._label_row(lines, "HDMI-A-1") r_d1 = self._label_row(lines, "DP-1") # HDMI's label is vertically between DP-2 and DP-1 labels self.assertLess(r_d2, r_h) self.assertLess(r_h, r_d1) # DP-2 and DP-1 must share their horizontal edge: there must be a # single horizontal edge row between their labels (NOT two with a # blank gap, which is what the old banding algo produced). edge_row = self._next_horiz_edge(lines, r_d2 + 1) self.assertIsNotNone(edge_row, "no shared edge row found between DP-2 and DP-1") self.assertLess(edge_row, r_d1) # Row right after edge_row must be interior of DP-1 (few dashes), # not another horizontal edge — proves there's no gap band. interior = lines[edge_row + 1] if edge_row + 1 < len(lines) else "" self.assertLess(interior.count("-"), 5, f"expected DP-1 interior right after shared edge at row " f"{edge_row}, got another edge: {interior!r}") def test_dual_stacked_share_boundary(self): ds = [ Display(name="DP-1", width=5120, height=1440, x=0, y=0, primary=True, manufacturer_id="SAM"), Display(name="DP-2", width=5120, height=1440, x=0, y=1440, manufacturer_id="SAM"), ] lines = render_layout(ds, term_cols=80) r_d1 = self._label_row(lines, "DP-1") r_d2 = self._label_row(lines, "DP-2") self.assertLess(r_d1, r_d2) edge_row = self._next_horiz_edge(lines, r_d1 + 1) self.assertIsNotNone(edge_row) self.assertLess(edge_row, r_d2) interior = lines[edge_row + 1] if edge_row + 1 < len(lines) else "" self.assertLess(interior.count("-"), 5, "two horizontal edges in a row means there's a gap band") def test_horizontal_row_no_double_pipe(self): """Adjacent displays with touching x-coords must not render '||' between them (regression for cumulative rounding error).""" ds = [ Display(name="DP-1", width=1920, height=1080, x=0, y=0), Display(name="DP-2", width=1920, height=1080, x=1920, y=0, primary=True), Display(name="DP-3", width=1920, height=1080, x=3840, y=0), ] lines = render_layout(ds, term_cols=80) for ln in lines: self.assertNotIn("||", ln, f"adjacent boxes should share an edge, got: {ln!r}") class TestCLI(unittest.TestCase): """Test CLI invocations.""" def _run(self, *args): script = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "lsdisplay.py") return subprocess.run( [sys.executable, script] + list(args), capture_output=True, text=True, timeout=10 ) def test_help(self): r = self._run("--help") self.assertEqual(r.returncode, 0) self.assertIn("lsdisplay", r.stdout) def test_version(self): r = self._run("--version") self.assertEqual(r.returncode, 0) self.assertIn("1.", r.stdout) def test_json_output(self): r = self._run("--json") if r.returncode == 0: data = json.loads(r.stdout) self.assertIsInstance(data, list) def test_short_output(self): r = self._run("--short") if r.returncode == 0: self.assertGreater(len(r.stdout), 0) def test_no_layout(self): r = self._run("--no-layout") if r.returncode == 0: self.assertNotIn("LAYOUT", r.stdout) def test_no_color(self): r = self._run("--no-layout", "--no-color") if r.returncode == 0: self.assertNotIn("\033[", r.stdout) if __name__ == "__main__": unittest.main()