├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── document-issues.md │ ├── software-bug-report.md │ ├── defined-printers.md │ └── new-printers.md └── workflows │ ├── stale.yml │ ├── python-syntax-checker.yml │ ├── jekyll-gh-pages.yml │ ├── codeql-analysis.yml │ └── build.yml ├── requirements.txt ├── SECURITY.md ├── .jekyll-gh-pages ├── Gemfile ├── just-the-readme.gemspec └── _config.yml ├── Dockerfile ├── start.sh ├── RELEASES.md ├── CONTRIBUTING.md ├── find_printers.py ├── .gitignore ├── epson_print_conf.spec ├── LICENSE ├── parse_devices.py ├── SNMP_LIB_PERF_COMP.md └── README.md /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyyaml 2 | pysnmp>=7.1.20 3 | pysnmp_sync_adapter>=1.0.8 4 | tkcalendar 5 | pyperclip 6 | black 7 | tomli 8 | text-console>=2.0.7 9 | hexdump2 10 | pyprintlpr>=1.0.3 11 | epson_escp2>=1.0.2 12 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Epson Printer Configuration tool Security Policy 2 | 3 | Security bugs will be taken seriously and, if confirmed upon investigation, a new patch will be released within a reasonable amount of time, including a security bulletin and the credit to the discoverer. 4 | 5 | ## Reporting a Security Bug 6 | 7 | The way to report a security bug is to open an [issue](https://github.com/Ircama/epson_print_conf/issues) including related information (e.g., reproduction steps, version). 8 | -------------------------------------------------------------------------------- /.jekyll-gh-pages/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | gem "jekyll-github-metadata", ">= 2.15" 5 | 6 | gem "jekyll-include-cache", group: :jekyll_plugins 7 | gem "jekyll-sitemap", group: :jekyll_plugins 8 | 9 | gem "html-proofer", "~> 5.0", :group => :development 10 | 11 | gem 'jekyll-autolinks' 12 | 13 | gem 'kramdown-parser-gfm' 14 | gem "jekyll-remote-theme" 15 | 16 | #------------------------------------------------------------------------------------------------ 17 | # After modifying the Gemfile: 18 | #------------------------------------------------------------------------------------------------ 19 | #bundle install 20 | #bundle exec jekyll serve 21 | -------------------------------------------------------------------------------- /.jekyll-gh-pages/just-the-readme.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "just-the-readme" 5 | spec.version = "0.0.1" 6 | spec.authors = ["Ircama"] 7 | 8 | spec.summary = %q{A modern, highly customizable, and responsive Jekyll theme for README documentation with built-in search.} 9 | spec.homepage = "https://github.com/Ircama/just-the-readme" 10 | spec.license = "MIT" 11 | 12 | spec.add_development_dependency "bundler", ">= 2.3.5" 13 | spec.add_runtime_dependency "sass-embedded", "~> 1.78.0" # Fix use of deprecated sass lighten() and darken() 14 | spec.add_runtime_dependency "jekyll", ">= 3.8.5" 15 | spec.add_runtime_dependency "jekyll-seo-tag", ">= 2.0" 16 | spec.add_runtime_dependency "jekyll-include-cache" 17 | spec.add_runtime_dependency "rake", ">= 12.3.1" 18 | spec.add_runtime_dependency "base64" 19 | spec.add_runtime_dependency "csv" 20 | end 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/document-issues.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation issues 3 | about: Use this option for request of clarifications clearly not reported in the README. Not for usage support. 4 | title: '' 5 | labels: 'documentation' 6 | assignees: '' 7 | --- 8 | 15 | 16 | **Describe the bug** 17 | Provide a clear and concise description of the issue in the documentation, specifying the exact point in the README where the incorrect information appears, and suggest a correction. 18 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and automatically close them 2 | 3 | on: 4 | schedule: 5 | - cron: '00 23 * * *' 6 | 7 | jobs: 8 | stale: 9 | 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | 15 | steps: 16 | - uses: actions/stale@v9 # https://github.com/actions/stale 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | stale-issue-message: 'This issue becomed stale because of no feedback for 15 days. Remove the stale label or add a comment; otherwise, this will be automatically closed in 30 days.' 20 | stale-pr-message: 'This PR becomed stale because of no feedback for 30 days.' 21 | days-before-stale: 15 22 | days-before-close: 30 23 | close-issue-message: 'This issue was closed because it has been stalled for 30 days with no activity.' 24 | days-before-pr-close: -1 25 | any-of-labels: answered,needs-rebase,inactive,Awaiting-Response,question,invalid,duplicate,wontfix,comment 26 | exempt-all-pr-assignees: true 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/software-bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Software bug report or feature enhancement 3 | about: This option is only for software bugs or to propose software enhancements. Not to be used for usage clarifications or for requests about printers. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 13 | 14 | **Describe the bug** 15 | A clear and concise description of the bug, including steps to reproduce the behavior and a short description of what you expected to happen. 16 | This option is only for software bugs. 17 | We do not accept questions about usage clarifications or requests about printers. 18 | 19 | **Software version** 20 | The exact version of the tested software. 21 | 22 | **Additional context** 23 | Feel free to add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Python slim image 2 | FROM python:3.11-slim 3 | 4 | USER root 5 | 6 | # Install system dependencies including Tkinter, Xvfb, and X11 utilities 7 | RUN apt update && apt install -y \ 8 | git \ 9 | tk \ 10 | tcl \ 11 | libx11-6 \ 12 | libxrender-dev \ 13 | libxext-dev \ 14 | libxinerama-dev \ 15 | libxi-dev \ 16 | libxrandr-dev \ 17 | libxcursor-dev \ 18 | libxtst-dev \ 19 | tk-dev \ 20 | xvfb \ 21 | x11-apps \ 22 | x11vnc \ 23 | fluxbox \ 24 | procps \ 25 | && rm -rf /var/lib/apt/lists/* 26 | 27 | # Set working directory 28 | WORKDIR /app 29 | 30 | RUN mkdir ~/.vnc 31 | RUN x11vnc -storepasswd 1234 ~/.vnc/passwd 32 | 33 | # Copy only requirements first to leverage Docker cache 34 | COPY requirements.txt . 35 | 36 | # Install Python dependencies 37 | RUN pip install --break-system-packages --no-cache-dir -r requirements.txt 38 | 39 | # Then copy the rest of the app 40 | COPY . . 41 | 42 | # Set the DISPLAY environment variable for Xvfb 43 | ENV DISPLAY=:99 44 | 45 | # Expose the VNC port 46 | EXPOSE 5990 47 | 48 | # Set the entrypoint to automatically run the script 49 | ENTRYPOINT ["bash", "/app/start.sh"] 50 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Update repo at runtime 5 | if [ ! -d /app/.git ]; then 6 | git clone https://github.com/Ircama/epson_print_conf.git /app 7 | else 8 | cd /app && git pull 9 | fi 10 | 11 | echo "Starting Xvfb virtual display..." 12 | Xvfb :99 -screen 0 1280x800x24 & 13 | 14 | sleep 2 15 | 16 | echo "Creating minimal Fluxbox config..." 17 | mkdir -p ~/.fluxbox 18 | 19 | # Create minimal init file 20 | cat > ~/.fluxbox/init <<'EOF' 21 | # Minimal Fluxbox config for Docker/Xvfb 22 | session.screen0.toolbar.visible: false 23 | session.screen0.slit.placement: BottomRight 24 | session.screen0.slit.direction: Horizontal 25 | session.screen0.fullMaximization: true 26 | session.screen0.workspaces: 1 27 | session.screen0.focusModel: sloppy 28 | session.keyFile: ~/.fluxbox/keys 29 | session.appsFile: ~/.fluxbox/apps 30 | EOF 31 | 32 | echo "Starting Fluxbox window manager..." 33 | fluxbox -log ~/.fluxbox/fb.log 2>&1 & # Redirect logs to a file instead of the console 34 | 35 | sleep 2 36 | 37 | echo "Starting VNC server..." 38 | x11vnc -display :99 -forever -nopw -bg -rfbport 5990 -ncache 10 -ncache_cr & 39 | 40 | sleep 2 41 | 42 | echo "Starting Tkinter application. Open your VNC client and connect to localhost:90..." 43 | exec python3 ui.py 44 | -------------------------------------------------------------------------------- /RELEASES.md: -------------------------------------------------------------------------------- 1 | # Tagging 2 | 3 | Push all changes: 4 | 5 | ```shell 6 | git commit -a 7 | git push 8 | ``` 9 | 10 | _After pushing the last commit_, add a local tag (shall be added AFTER the commit that needs to rebuild the exe): 11 | 12 | ```shell 13 | git tag # list local tags 14 | git tag v2.1.3 15 | ``` 16 | 17 | Push this tag to the origin, which starts the rebuild workflow (GitHub Action): 18 | 19 | ```shell 20 | git push origin v2.1.3 21 | git ls-remote --tags https://github.com/Ircama/epson_print_conf # list remote tags 22 | ``` 23 | 24 | Check the published tag here: https://github.com/Ircama/epson_print_conf/tags 25 | 26 | It shall be even with the last commit. 27 | 28 | Check the GitHub Action: https://github.com/Ircama/epson_print_conf/actions 29 | 30 | # Updating the same tag (using a different build number for publishing) 31 | 32 | ```shell 33 | git tag # list tags 34 | git tag -d epson_print_conf # remove local tag 35 | git push --delete origin epson_print_conf # remove remote tag 36 | git ls-remote --tags https://github.com/Ircama/epson_print_conf # list remote tags 37 | ``` 38 | 39 | Then follow the tagging procedure again to add the tag to the latest commit. 40 | 41 | # Pushing the docker container 42 | 43 | ```shell 44 | sudo docker login 45 | sudo docker buildx build --platform linux/amd64,linux/arm64 -t ircama/epson_print_conf --push . 46 | 47 | sudo docker run --publish 5990:5990 ircama/epson_print_conf 48 | ``` 49 | -------------------------------------------------------------------------------- /.github/workflows/python-syntax-checker.yml: -------------------------------------------------------------------------------- 1 | # from https://docs.github.com/en/actions/guides/building-and-testing-python 2 | 3 | name: Python syntax checker 4 | 5 | on: [push] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | # version range and latest minor release (possibly 3.9.1) 14 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.x'] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install flake8 28 | # next line is alternative to the previous one, for future use of pytest 29 | # pip install flake8 pytest 30 | # next line is for future use of requirements file 31 | # if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 32 | 33 | - name: Lint with flake8 34 | run: | 35 | # stop the build if there are Python syntax errors or undefined names 36 | flake8 . --count --select=E9,F63,F7,F72,F82 --show-source --statistics 37 | # next lines is for future use of more accurate statistics 38 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 39 | # flake8 . --count --exit-zero --max-complexity=40 --max-line-length=127 --statistics 40 | # next lines is for future use of pytest 41 | #- name: Test with pytest 42 | # run: | 43 | # pytest 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/defined-printers.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Request on printers included in the software 3 | about: This option is only related to already defined printers which are not appropriately managed by the software. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 17 | 18 | **Printer model** 19 | Provide the printer model as reported by the product label. This model shall be available in the drop-down list of the UI. It should clearly confirm that the printer in question matches the configuration specified in the software without ambiguity. 20 | 21 | **Software configuration** 22 | In the UI, select the model from the drop-down list; press F2 in correspondence to your model. The status box will show the printer definition values. Select the status box, right button of the mouse, then copy all data and paste them here. 23 | 24 | **Printer status** 25 | Press "Printer status". The status box will show a tree view with printer data. Select the status box, right button of the mouse, then copy all data and paste them here. 26 | 27 | **Detect configuration** 28 | Press "Detect configuration". After the operation is completed, the status box will show a tree view with printer data. Select the status box, right button of the mouse, then copy all data and paste them here. 29 | 30 | **Describe the issue** 31 | Provide a short description of the problem including steps to reproduce the behavior and a short description of what you expected to happen. 32 | 33 | **Additional context** 34 | Optionally add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/workflows/jekyll-gh-pages.yml: -------------------------------------------------------------------------------- 1 | # Workflow for building and deploying a Jekyll site to GitHub Pages 2 | name: Deploy Jekyll with GitHub Pages dependencies preinstalled 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Build job (github-pages-build) 26 | build: 27 | name: Build (github-pages-build gem) 28 | runs-on: ubuntu-latest 29 | env: 30 | PAGES_REPO_NWO: ${{ github.repository }} 31 | JEKYLL_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | JEKYLL_ENV: production 33 | NODE_ENV: production 34 | steps: 35 | - name: Checkout Repository 36 | uses: actions/checkout@v4 37 | - name: Setup environment 38 | run: | 39 | mv .jekyll-gh-pages/* . 40 | - name: Setup Ruby 41 | uses: ruby/setup-ruby@v1 42 | with: 43 | ruby-version: "3.3" 44 | bundler-cache: true # runs 'bundle install' to install and cache gems 45 | - name: Add Google Search Console verification file 46 | run: | 47 | echo "google-site-verification: googlecef4261bbf37d740.html" > googlecef4261bbf37d740.html 48 | - name: Add a YAML front matter to README.md 49 | run: | 50 | ex README.md < 21 | 22 | **Scenario** 23 | Clearly describe that you followed the instructions in the README and specifically that you carefully completed the steps described in the [Example to integrate new printers](https://github.com/Ircama/epson_print_conf/?tab=readme-ov-file#example-to-integrate-new-printers). Report the issue including steps to reproduce the behavior and a short description of what you expected to happen. 24 | 25 | **Printer model** 26 | Provide the exact printer model. 27 | 28 | **Printer status** 29 | Press "Printer status" in the UI. The status box will show a tree view with printer data. Select the status box, right button of the mouse, then copy all data and paste them here. 30 | 31 | **Detect Access Keys** 32 | Press "Detect Access Keys". After the operation is completed, the status box will show a tree view with printer data. Select the status box, right button of the mouse, then copy all data and paste them here. 33 | 34 | **Detect configuration** 35 | Press "Detect configuration". After the operation is completed, the status box will show a tree view with printer data. Select the status box, right button of the mouse, then copy all data and paste them here. 36 | 37 | **Additional context** 38 | Feel free to add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # epson_print_conf contribution guidelines 2 | 3 | Contributions might involve: 4 | 5 | - Adding features and enhancemeents 6 | - Updating code 7 | - Performing corrections and bug fixing 8 | 9 | In all these cases, we operate the "Fork & Pull" model explained at 10 | 11 | https://help.github.com/articles/about-pull-requests/ 12 | 13 | First contributors can follow a [tutorial page](https://github.com/firstcontributions/first-contributions). 14 | 15 | Please discuss the change you wish to make via an [issue](https://github.com/Ircama/epson_print_conf/issues) before developing a pull request. 16 | 17 | # Bugs 18 | 19 | You can help to report bugs by filing an [issue](https://github.com/Ircama/epson_print_conf/issues) on the software or on the documentation. 20 | 21 | # Code of Conduct 22 | 23 | ### Our Pledge 24 | 25 | In the interest of fostering an open and welcoming environment, we as 26 | contributors and owner pledge to making participation in our project and our 27 | community a harassment-free experience for everyone, regardless of age, body 28 | size, disability, ethnicity, gender identity and expression, level of experience, 29 | nationality, personal appearance, race, religion, or sexual identity and 30 | orientation. 31 | 32 | ### Our Standards 33 | 34 | Examples of behavior that contributes to creating a positive environment 35 | include: 36 | 37 | * Using welcoming and inclusive language 38 | * Being respectful of differing viewpoints and experiences 39 | * Gracefully accepting constructive criticism 40 | * Focusing on what is best for the community 41 | * Showing empathy towards other community members 42 | 43 | Examples of unacceptable behavior by participants include: 44 | 45 | * The use of improper language 46 | * Trolling, insulting/derogatory comments, and personal or political attacks 47 | * Public or private harassment 48 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 49 | * Other conduct which could reasonably be considered inappropriate in a professional setting 50 | 51 | ### Our Responsibilities 52 | 53 | Project owner is responsible for clarifying the standards of acceptable 54 | behavior and is expected to take appropriate and fair corrective action in 55 | response to any instances of unacceptable behavior. 56 | 57 | Project owner has the right and responsibility to remove, edit, or 58 | reject comments, commits, code, wiki edits, issues, and other contributions 59 | that are not aligned to this Code of Conduct, or to ban temporarily or 60 | permanently any contributor for other behaviors that he deems inappropriate, 61 | threatening, offensive, or harmful. 62 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '39 8 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | permissions: write-all 26 | name: Analyze 27 | runs-on: ubuntu-latest 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | language: [ 'python' ] 33 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 34 | # Learn more: 35 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 36 | 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v2 40 | 41 | # Initializes the CodeQL tools for scanning. 42 | - name: Initialize CodeQL 43 | uses: github/codeql-action/init@v1 44 | with: 45 | languages: ${{ matrix.language }} 46 | # If you wish to specify custom queries, you can do so here or in a config file. 47 | # By default, queries listed here will override any specified in a config file. 48 | # Prefix the list here with "+" to use these queries and those in the config file. 49 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 50 | 51 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 52 | # If this step fails, then you should remove it and run the build manually (see below) 53 | - name: Autobuild 54 | uses: github/codeql-action/autobuild@v1 55 | 56 | # ℹ️ Command-line programs to run using the OS shell. 57 | # 📚 https://git.io/JvXDl 58 | 59 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 60 | # and modify them (or add more) to build your code if your project 61 | # uses a compiled language 62 | 63 | #- run: | 64 | # make bootstrap 65 | # make release 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@v1 69 | -------------------------------------------------------------------------------- /find_printers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | import subprocess 4 | import threading 5 | import warnings 6 | 7 | from epson_print_conf import EpsonPrinter 8 | 9 | # suppress pysnmp warnings 10 | warnings.filterwarnings("ignore", category=SyntaxWarning) 11 | 12 | # common printer ports 13 | PRINTER_PORTS = [9100, 515, 631] 14 | 15 | 16 | class PrinterScanner: 17 | 18 | def check_printer(self, ip, port): 19 | try: 20 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 21 | sock.settimeout(1) 22 | sock.connect((ip, port)) 23 | sock.close() 24 | return True 25 | except socket.error: 26 | return False 27 | 28 | def get_printer_name(self, ip): 29 | printer = EpsonPrinter(hostname=ip) 30 | try: 31 | printer_info = printer.get_snmp_info("Model") 32 | return printer_info["Model"] 33 | except: 34 | return None 35 | 36 | def scan_ip(self, ip): 37 | for port in PRINTER_PORTS: 38 | if self.check_printer(ip, port): 39 | try: 40 | hostname = socket.gethostbyaddr(ip)[0] 41 | except socket.herror: 42 | hostname = "Unknown" 43 | 44 | return { 45 | "ip": ip, 46 | "hostname": hostname, 47 | } 48 | return None 49 | 50 | def get_all_printers(self, ip_addr="", local=False): 51 | if ip_addr: 52 | result = self.scan_ip(ip_addr) 53 | if result: 54 | result["name"] = self.get_printer_name(result['ip']) 55 | return [result] 56 | local_device_ip_list = socket.gethostbyname_ex(socket.gethostname())[2] 57 | if local: 58 | return local_device_ip_list # IP list 59 | printers = [] 60 | for local_device_ip in local_device_ip_list: 61 | if ip_addr and not local_device_ip.startswith(ip_addr): 62 | continue 63 | base_ip = local_device_ip[:local_device_ip.rfind('.') + 1] 64 | ips=[f"{base_ip}{i}" for i in range(1, 255)] 65 | threads = [] 66 | 67 | def worker(ip): 68 | result = self.scan_ip(ip) 69 | if result: 70 | printers.append(result) 71 | 72 | for ip in ips: 73 | thread = threading.Thread(target=worker, args=(ip,)) 74 | threads.append(thread) 75 | thread.start() 76 | 77 | for thread in threads: 78 | thread.join() 79 | 80 | for i in printers: 81 | i["name"] = self.get_printer_name(i['ip']) 82 | return printers 83 | 84 | 85 | if __name__ == "__main__": 86 | import sys 87 | ip = "" 88 | if len(sys.argv) > 1: 89 | ip = sys.argv[1] 90 | scanner = PrinterScanner() 91 | print(scanner.get_all_printers(ip)) 92 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Workflow for epson_print_conf.exe/zip 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | build-windows: 10 | runs-on: windows-latest 11 | steps: 12 | - name: Git Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | ref: main 16 | fetch-depth: 0 # Fetch all history for accurate commits and tags 17 | 18 | - name: Update VERSION in python file ui.py; commit and push updates 19 | run: | 20 | $filePath = "ui.py" 21 | $VERSION = "${{ github.ref_name }}" 22 | if ($VERSION.StartsWith('v')) { 23 | $VERSION = $VERSION.Substring(1) 24 | } 25 | (Get-Content $filePath) -replace '^VERSION = ".*"$', "VERSION = `"$VERSION`"" | Set-Content $filePath 26 | git config --local user.name "github-actions[bot]" 27 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 28 | git add $filePath 29 | git commit -m "Update VERSION to ${{ github.ref_name }}" 30 | git remote set-url origin https://${GITHUB_USER}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} 31 | git push origin main 32 | 33 | - name: Install Python 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: '3.11' 37 | architecture: 'x64' 38 | 39 | - name: Install Python Requirements 40 | run: | 41 | python -m pip install --upgrade pip 42 | #pip install git+https://github.com/pyinstaller/pyinstaller@develop 43 | pip install pyinstaller 44 | pip install Pillow 45 | pip install -r requirements.txt 46 | 47 | - name: Run PyInstaller to create epson_print_conf.exe 48 | run: python -m PyInstaller epson_print_conf.spec -- --default 49 | 50 | - name: Zip the epson_print_conf.exe asset to epson_print_conf.zip 51 | run: | 52 | Compress-Archive dist/epson_print_conf.exe dist/epson_print_conf.zip 53 | shell: pwsh 54 | 55 | - name: Generate Changelog 56 | run: > 57 | echo "The *epson_print_conf.exe* executable file in the 58 | *epson_print_conf.zip* archive within the assets below is 59 | auto-generated by a [GitHub Action](.github/workflows/build.yml). 60 |

61 | Check the 62 | [History of modifications](https://github.com/Ircama/epson_print_conf/commits/main/). 63 | " 64 | > ${{ github.workspace }}-CHANGELOG.txt 65 | 66 | - name: Create Release, uploading the epson_print_conf.zip asset 67 | uses: softprops/action-gh-release@v2 68 | if: startsWith(github.ref, 'refs/tags/') 69 | with: 70 | body_path: ${{ github.workspace }}-CHANGELOG.txt 71 | files: dist/epson_print_conf.zip 72 | append_body: true 73 | generate_release_notes: false 74 | 75 | - name: Remove old releases 76 | uses: Nats-ji/delete-old-releases@v1 77 | with: 78 | token: ${{ secrets.GITHUB_TOKEN }} 79 | keep-count: 1 80 | keep-old-minor-releases: false 81 | keep-old-minor-releases-count: 1 82 | remove-tags: true 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .idea/* 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | # *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | devices.xml 164 | *.toml 165 | *.srs 166 | *.pickle 167 | 168 | .console_history 169 | 170 | lpr_jobs/ -------------------------------------------------------------------------------- /epson_print_conf.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | import argparse 4 | import os 5 | import os.path 6 | from PIL import Image, ImageDraw, ImageFont 7 | from PyInstaller.utils.hooks import collect_submodules, collect_data_files 8 | 9 | 10 | def create_image(png_file, text): 11 | x_size = 800 12 | y_size = 150 13 | font_size = 30 14 | img = Image.new('RGB', (x_size, y_size), color='black') 15 | fnt = ImageFont.truetype('arialbd.ttf', font_size) 16 | d = ImageDraw.Draw(img) 17 | shadow_offset = 2 18 | bbox = d.textbbox((0, 0), text, font=fnt) 19 | x, y = (x_size-bbox[2])/2, (y_size-bbox[3])/2 20 | d.text((x+shadow_offset, y+shadow_offset), text, font=fnt, fill='gray') 21 | d.text((x, y), text, font=fnt, fill='#baf8f8') 22 | img.save(png_file, 'PNG') 23 | 24 | 25 | parser = argparse.ArgumentParser() 26 | parser.add_argument("--default", action="store_true") 27 | parser.add_argument("--version", action="store", default=None) 28 | options = parser.parse_args() 29 | 30 | PROGRAM = [ 'gui.py' ] 31 | BASENAME = 'epson_print_conf' 32 | 33 | DATAS = [(BASENAME + '.pickle', '.')] 34 | SPLASH_IMAGE = BASENAME + '.png' 35 | 36 | version = ( 37 | "ui.VERSION = '" + options.version.replace('v', '') + "'" 38 | ) if options.version else "" 39 | 40 | create_image( 41 | SPLASH_IMAGE, 'Epson Printer Configuration tool loading...' 42 | ) 43 | 44 | if not options.default and not os.path.isfile(DATAS[0][0]): 45 | print("\nMissing file", DATAS[0][0], "without using the default option.") 46 | quit() 47 | 48 | gui_wrapper = """import pyi_splash 49 | import pickle 50 | import ui 51 | from os import path 52 | 53 | """ + version + """ 54 | path_to_pickle = path.abspath( 55 | path.join(path.dirname(__file__), '""" + DATAS[0][0] + """') 56 | ) 57 | with open(path_to_pickle, 'rb') as fp: 58 | conf_dict = pickle.load(fp) 59 | app = ui.EpsonPrinterUI(conf_dict=conf_dict, replace_conf=False) 60 | pyi_splash.close() 61 | app.mainloop() 62 | """ 63 | 64 | if options.default: 65 | DATAS = [] 66 | gui_wrapper = """import pyi_splash 67 | import pickle 68 | import ui 69 | from os import path 70 | 71 | """ + version + """ 72 | app = ui.main() 73 | pyi_splash.close() 74 | app.mainloop() 75 | """ 76 | 77 | with open(PROGRAM[0], 'w') as file: 78 | file.write(gui_wrapper) 79 | 80 | # black submodules: https://github.com/pyinstaller/pyinstaller/issues/8270 81 | black_submodules = collect_submodules('black') 82 | blib2to3_submodules = collect_submodules('blib2to3') 83 | 84 | # "black" data files: https://github.com/pyinstaller/pyinstaller/issues/8270 85 | blib2to3_data = collect_data_files('blib2to3') 86 | 87 | a = Analysis( 88 | PROGRAM, 89 | pathex=[], 90 | binaries=[], 91 | datas=DATAS + blib2to3_data, # the latter required by black 92 | hiddenimports=[ 93 | 'babel.numbers', 94 | # The following modules are needed by "black": https://github.com/pyinstaller/pyinstaller/issues/8270 95 | '30fcd23745efe32ce681__mypyc', 96 | '3c22db458360489351e4__mypyc', 97 | '6b397dd64e00b5aff23d__mypyc', 'click', 'json', 'platform', 98 | 'mypy_extensions', 'pathspec', '_black_version', 'platformdirs' 99 | ] + black_submodules + blib2to3_submodules, # the last two required by black 100 | hookspath=[], 101 | hooksconfig={}, 102 | runtime_hooks=[], 103 | excludes=[], 104 | noarchive=False, 105 | optimize=0, 106 | ) 107 | 108 | pyz = PYZ(a.pure) 109 | splash = Splash( 110 | SPLASH_IMAGE, 111 | binaries=a.binaries, 112 | datas=a.datas, 113 | text_pos=None, 114 | text_size=12, 115 | minify_script=True, 116 | always_on_top=True, 117 | ) 118 | 119 | exe = EXE( 120 | pyz, 121 | a.scripts, 122 | a.binaries, 123 | a.datas, 124 | splash, 125 | splash.binaries, 126 | [], 127 | name=BASENAME, 128 | debug=False, # Setting to True gives you progress messages from the executable (for console=False there will be annoying MessageBoxes on Windows). 129 | bootloader_ignore_signals=False, 130 | strip=False, 131 | upx=True, 132 | upx_exclude=[], 133 | runtime_tmpdir=None, 134 | console=options.default, # On Windows or Mac OS governs whether to use the console executable or the windowed executable. Always True on Linux/Unix (always console executable - it does not matter there). 135 | disable_windowed_traceback=False, # Disable traceback dump of unhandled exception in windowed (noconsole) mode (Windows and macOS only) 136 | hide_console='hide-early', # Windows only. In console-enabled executable, hide or minimize the console window ('hide-early', 'minimize-early', 'hide-late', 'minimize-late') 137 | argv_emulation=False, 138 | target_arch=None, 139 | codesign_identity=None, 140 | entitlements_file=None, 141 | ) 142 | 143 | os.remove(SPLASH_IMAGE) 144 | os.remove(PROGRAM[0]) 145 | -------------------------------------------------------------------------------- /.jekyll-gh-pages/_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: ircama/just-the-readme 2 | 3 | # Enable or disable the site search 4 | # Supports true (default) or false 5 | search_enabled: true 6 | 7 | # For copy button on code 8 | enable_copy_code_button: true 9 | 10 | # Table of Contents 11 | # Enable or disable the Table of Contents globally 12 | # Supports true (default) or false 13 | toc_enabled: true 14 | toc: 15 | # Minimum header level to include in ToC 16 | # Default: 1 17 | min_level: 1 18 | # Maximum header level to include in ToC 19 | # Default: 6 20 | max_level: 6 21 | # Display the ToC as ordered list instead of an unordered list 22 | # Supports true (default) or false 23 | ordered: true 24 | # Whether ToC will be a single level list 25 | # Supports true or false (default) 26 | flat_toc: false 27 | 28 | # By default, consuming the theme as a gem leaves mermaid disabled; it is opt-in 29 | mermaid: 30 | # Version of mermaid library 31 | # Pick an available version from https://cdn.jsdelivr.net/npm/mermaid/ 32 | version: "9.1.6" 33 | # Put any additional configuration, such as setting the theme, in _includes/mermaid_config.js 34 | # See also docs/ui-components/code 35 | # To load mermaid from a local library, also use the `path` key to specify the location of the library; e.g. 36 | # for (v10+): 37 | # path: "/assets/js/mermaid.esm.min.mjs" 38 | # for (EUPL-1.2 License.' 81 | 82 | # Footer last edited timestamp 83 | last_edit_timestamp: true # show or hide edit time - page must have `last_modified_date` defined in the frontmatter 84 | last_edit_time_format: "%b %e %Y at %I:%M %p" # uses ruby's time format: https://ruby-doc.org/stdlib-2.7.0/libdoc/time/rdoc/Time.html 85 | 86 | # Footer "Edit this page on GitHub" link text 87 | gh_edit_link: true # show or hide edit this page link 88 | gh_edit_link_text: "Edit this page on GitHub" 89 | gh_edit_repository: "https://github.com/Ircama/epson_print_conf" # the github URL for your repo 90 | gh_edit_branch: "main" # the branch that your docs is served from 91 | # gh_edit_source: docs # the source that your files originate from 92 | gh_edit_view_mode: "tree" # "tree" or "edit" if you want the user to jump into the editor immediately 93 | 94 | # Color scheme currently only supports "dark", "light"/nil (default), or a custom scheme that you define 95 | color_scheme: nil 96 | 97 | callouts_level: quiet # or loud 98 | callouts: 99 | highlight: 100 | color: yellow 101 | important: 102 | title: Important 103 | color: blue 104 | new: 105 | title: New 106 | color: green 107 | note: 108 | title: Note 109 | color: purple 110 | warning: 111 | title: Warning 112 | color: red 113 | 114 | # Google Analytics Tracking (optional) 115 | # Supports a CSV of tracking ID strings (eg. "UA-1234567-89,G-1AB234CDE5") 116 | # Note: the main Just the Docs site does *not* use Google Analytics. 117 | # ga_tracking: UA-2709176-10,G-5FG1HLH3XQ 118 | # ga_tracking_anonymize_ip: true # Use GDPR compliant Google Analytics settings (true/nil by default) 119 | 120 | # Google Tag Manager: GTM-W3MQKRL3 121 | ga_tracking: G-D8T6QN7MKL 122 | 123 | plugins: 124 | - jekyll-seo-tag 125 | - jekyll-github-metadata 126 | - jekyll-sitemap 127 | - jekyll-autolinks 128 | - jekyll-remote-theme # Add if not already present 129 | - jekyll-include-cache # Optional, for caching 130 | 131 | kramdown: 132 | syntax_highlighter_opts: 133 | block: 134 | line_numbers: false 135 | 136 | compress_html: 137 | clippings: all 138 | comments: all 139 | endings: all 140 | startings: [] 141 | blanklines: false 142 | profile: false 143 | # ignore: 144 | # envs: all 145 | 146 | autolinks: 147 | link_attr: 'target="_blank"' 148 | skip_tags: ["a", "pre", "code", "kbd", "script"] 149 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024-2025 Ircama, EUPL-1.2 2 | 3 | Corresponding SPDX licence: European Union Public License 1.2 4 | 5 | Licence ID: EUPL-1.2 6 | 7 | See also: 8 | https://opensource.org/licenses/EUPL-1.2 9 | https://joinup.ec.europa.eu/software/page/eupl/licence-eupl 10 | 11 | European Union Public Licence 12 | V. 1.2 13 | 14 | EUPL © the European Union 2007, 2016 15 | 16 | This European Union Public Licence (the ‘EUPL’) applies to the Work (as 17 | defined below) which is provided under the terms of this Licence. Any use of 18 | the Work, other than as authorised under this Licence is prohibited (to the 19 | extent such use is covered by a right of the copyright holder of the Work). 20 | 21 | The Work is provided under the terms of this Licence when the Licensor (as 22 | defined below) has placed the following notice immediately following the 23 | copyright notice for the Work: “Licensed under the EUPL”, or has expressed by 24 | any other means his willingness to license under the EUPL. 25 | 26 | 1. Definitions 27 | 28 | In this Licence, the following terms have the following meaning: 29 | — ‘The Licence’: this Licence. 30 | — ‘The Original Work’: the work or software distributed or communicated by the 31 | ‘Licensor under this Licence, available as Source Code and also as 32 | ‘Executable Code as the case may be. 33 | — ‘Derivative Works’: the works or software that could be created by the 34 | ‘Licensee, based upon the Original Work or modifications thereof. This 35 | ‘Licence does not define the extent of modification or dependence on the 36 | ‘Original Work required in order to classify a work as a Derivative Work; 37 | ‘this extent is determined by copyright law applicable in the country 38 | ‘mentioned in Article 15. 39 | — ‘The Work’: the Original Work or its Derivative Works. 40 | — ‘The Source Code’: the human-readable form of the Work which is the most 41 | convenient for people to study and modify. 42 | 43 | — ‘The Executable Code’: any code which has generally been compiled and which 44 | is meant to be interpreted by a computer as a program. 45 | — ‘The Licensor’: the natural or legal person that distributes or communicates 46 | the Work under the Licence. 47 | — ‘Contributor(s)’: any natural or legal person who modifies the Work under 48 | the Licence, or otherwise contributes to the creation of a Derivative Work. 49 | — ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of 50 | the Work under the terms of the Licence. 51 | — ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, 52 | renting, distributing, communicating, transmitting, or otherwise making 53 | available, online or offline, copies of the Work or providing access to its 54 | essential functionalities at the disposal of any other natural or legal 55 | person. 56 | 57 | 2. Scope of the rights granted by the Licence 58 | 59 | The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, 60 | sublicensable licence to do the following, for the duration of copyright 61 | vested in the Original Work: 62 | 63 | — use the Work in any circumstance and for all usage, 64 | — reproduce the Work, 65 | — modify the Work, and make Derivative Works based upon the Work, 66 | — communicate to the public, including the right to make available or display 67 | the Work or copies thereof to the public and perform publicly, as the case 68 | may be, the Work, 69 | — distribute the Work or copies thereof, 70 | — lend and rent the Work or copies thereof, 71 | — sublicense rights in the Work or copies thereof. 72 | 73 | Those rights can be exercised on any media, supports and formats, whether now 74 | known or later invented, as far as the applicable law permits so. 75 | 76 | In the countries where moral rights apply, the Licensor waives his right to 77 | exercise his moral right to the extent allowed by law in order to make 78 | effective the licence of the economic rights here above listed. 79 | 80 | The Licensor grants to the Licensee royalty-free, non-exclusive usage rights 81 | to any patents held by the Licensor, to the extent necessary to make use of 82 | the rights granted on the Work under this Licence. 83 | 84 | 3. Communication of the Source Code 85 | 86 | The Licensor may provide the Work either in its Source Code form, or as 87 | Executable Code. If the Work is provided as Executable Code, the Licensor 88 | provides in addition a machine-readable copy of the Source Code of the Work 89 | along with each copy of the Work that the Licensor distributes or indicates, 90 | in a notice following the copyright notice attached to the Work, a repository 91 | where the Source Code is easily and freely accessible for as long as the 92 | Licensor continues to distribute or communicate the Work. 93 | 94 | 4. Limitations on copyright 95 | 96 | Nothing in this Licence is intended to deprive the Licensee of the benefits 97 | from any exception or limitation to the exclusive rights of the rights owners 98 | in the Work, of the exhaustion of those rights or of other applicable 99 | limitations thereto. 100 | 101 | 5. Obligations of the Licensee 102 | 103 | The grant of the rights mentioned above is subject to some restrictions and 104 | obligations imposed on the Licensee. Those obligations are the following: 105 | 106 | Attribution right: The Licensee shall keep intact all copyright, patent or 107 | trademarks notices and all notices that refer to the Licence and to the 108 | disclaimer of warranties. The Licensee must include a copy of such notices and 109 | a copy of the Licence with every copy of the Work he/she distributes or 110 | communicates. The Licensee must cause any Derivative Work to carry prominent 111 | notices stating that the Work has been modified and the date of modification. 112 | 113 | Copyleft clause: If the Licensee distributes or communicates copies of the 114 | Original Works or Derivative Works, this Distribution or Communication will be 115 | done under the terms of this Licence or of a later version of this Licence 116 | unless the Original Work is expressly distributed only under this version of 117 | the Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee 118 | (becoming Licensor) cannot offer or impose any additional terms or conditions 119 | on the Work or Derivative Work that alter or restrict the terms of the 120 | Licence. 121 | 122 | Compatibility clause: If the Licensee Distributes or Communicates Derivative 123 | Works or copies thereof based upon both the Work and another work licensed 124 | under a Compatible Licence, this Distribution or Communication can be done 125 | under the terms of this Compatible Licence. For the sake of this clause, 126 | ‘Compatible Licence’ refers to the licences listed in the appendix attached to 127 | this Licence. Should the Licensee's obligations under the Compatible Licence 128 | conflict with his/her obligations under this Licence, the obligations of the 129 | Compatible Licence shall prevail. 130 | 131 | Provision of Source Code: When distributing or communicating copies of the 132 | Work, the Licensee will provide a machine-readable copy of the Source Code or 133 | indicate a repository where this Source will be easily and freely available 134 | for as long as the Licensee continues to distribute or communicate the Work. 135 | 136 | Legal Protection: This Licence does not grant permission to use the trade 137 | names, trademarks, service marks, or names of the Licensor, except as required 138 | for reasonable and customary use in describing the origin of the Work and 139 | reproducing the content of the copyright notice. 140 | 141 | 6. Chain of Authorship 142 | 143 | The original Licensor warrants that the copyright in the Original Work granted 144 | hereunder is owned by him/her or licensed to him/her and that he/she has the 145 | power and authority to grant the Licence. 146 | 147 | Each Contributor warrants that the copyright in the modifications he/she 148 | brings to the Work are owned by him/her or licensed to him/her and that he/she 149 | has the power and authority to grant the Licence. 150 | 151 | Each time You accept the Licence, the original Licensor and subsequent 152 | Contributors grant You a licence to their contributions to the Work, under the 153 | terms of this Licence. 154 | 155 | 7. Disclaimer of Warranty 156 | 157 | The Work is a work in progress, which is continuously improved by numerous 158 | Contributors. It is not a finished work and may therefore contain defects or 159 | ‘bugs’ inherent to this type of development. 160 | 161 | For the above reason, the Work is provided under the Licence on an ‘as is’ 162 | basis and without warranties of any kind concerning the Work, including 163 | without limitation merchantability, fitness for a particular purpose, absence 164 | of defects or errors, accuracy, non-infringement of intellectual property 165 | rights other than copyright as stated in Article 6 of this Licence. 166 | 167 | This disclaimer of warranty is an essential part of the Licence and a 168 | condition for the grant of any rights to the Work. 169 | 170 | 8. Disclaimer of Liability 171 | 172 | Except in the cases of wilful misconduct or damages directly caused to natural 173 | persons, the Licensor will in no event be liable for any direct or indirect, 174 | material or moral, damages of any kind, arising out of the Licence or of the 175 | use of the Work, including without limitation, damages for loss of goodwill, 176 | work stoppage, computer failure or malfunction, loss of data or any commercial 177 | damage, even if the Licensor has been advised of the possibility of such 178 | damage. However, the Licensor will be liable under statutory product liability 179 | laws as far such laws apply to the Work. 180 | 181 | 9. Additional agreements 182 | 183 | While distributing the Work, You may choose to conclude an additional 184 | agreement, defining obligations or services consistent with this Licence. 185 | However, if accepting obligations, You may act only on your own behalf and on 186 | your sole responsibility, not on behalf of the original Licensor or any other 187 | Contributor, and only if You agree to indemnify, defend, and hold each 188 | Contributor harmless for any liability incurred by, or claims asserted against 189 | such Contributor by the fact You have accepted any warranty or additional 190 | liability. 191 | 192 | 10. Acceptance of the Licence 193 | 194 | The provisions of this Licence can be accepted by clicking on an icon ‘I 195 | agree’ placed under the bottom of a window displaying the text of this Licence 196 | or by affirming consent in any other similar way, in accordance with the rules 197 | of applicable law. Clicking on that icon indicates your clear and irrevocable 198 | acceptance of this Licence and all of its terms and conditions. 199 | 200 | Similarly, you irrevocably accept this Licence and all of its terms and 201 | conditions by exercising any rights granted to You by Article 2 of this 202 | Licence, such as the use of the Work, the creation by You of a Derivative Work 203 | or the Distribution or Communication by You of the Work or copies thereof. 204 | 205 | 11. Information to the public 206 | 207 | In case of any Distribution or Communication of the Work by means of 208 | electronic communication by You (for example, by offering to download the Work 209 | from a remote location) the distribution channel or media (for example, a 210 | website) must at least provide to the public the information requested by the 211 | applicable law regarding the Licensor, the Licence and the way it may be 212 | accessible, concluded, stored and reproduced by the Licensee. 213 | 214 | 12. Termination of the Licence 215 | 216 | The Licence and the rights granted hereunder will terminate automatically upon 217 | any breach by the Licensee of the terms of the Licence. Such a termination 218 | will not terminate the licences of any person who has received the Work from 219 | the Licensee under the Licence, provided such persons remain in full 220 | compliance with the Licence. 221 | 222 | 13. Miscellaneous 223 | 224 | Without prejudice of Article 9 above, the Licence represents the complete 225 | agreement between the Parties as to the Work. 226 | 227 | If any provision of the Licence is invalid or unenforceable under applicable 228 | law, this will not affect the validity or enforceability of the Licence as a 229 | whole. Such provision will be construed or reformed so as necessary to make it 230 | valid and enforceable. 231 | 232 | The European Commission may publish other linguistic versions or new versions 233 | of this Licence or updated versions of the Appendix, so far this is required 234 | and reasonable, without reducing the scope of the rights granted by the 235 | Licence. New versions of the Licence will be published with a unique version 236 | number. 237 | 238 | All linguistic versions of this Licence, approved by the European Commission, 239 | have identical value. Parties can take advantage of the linguistic version of 240 | their choice. 241 | 242 | 14. Jurisdiction 243 | 244 | Without prejudice to specific agreement between parties, 245 | — any litigation resulting from the interpretation of this License, arising 246 | between the European Union institutions, bodies, offices or agencies, as a 247 | Licensor, and any Licensee, will be subject to the jurisdiction of the Court 248 | of Justice of the European Union, as laid down in article 272 of the Treaty 249 | on the Functioning of the European Union, 250 | — any litigation arising between other parties and resulting from the 251 | interpretation of this License, will be subject to the exclusive 252 | jurisdiction of the competent court where the Licensor resides or conducts 253 | its primary business. 254 | 255 | 15. Applicable Law 256 | 257 | Without prejudice to specific agreement between parties, 258 | — this Licence shall be governed by the law of the European Union Member State 259 | where the Licensor has his seat, resides or has his registered office, 260 | — this licence shall be governed by Belgian law if the Licensor has no seat, 261 | residence or registered office inside a European Union Member State. 262 | 263 | Appendix 264 | 265 | ‘Compatible Licences’ according to Article 5 EUPL are: 266 | — GNU General Public License (GPL) v. 2, v. 3 267 | — GNU Affero General Public License (AGPL) v. 3 268 | — Open Software License (OSL) v. 2.1, v. 3.0 269 | — Eclipse Public License (EPL) v. 1.0 270 | — CeCILL v. 2.0, v. 2.1 271 | — Mozilla Public Licence (MPL) v. 2 272 | — GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 273 | — Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for 274 | works other than software 275 | — European Union Public Licence (EUPL) v. 1.1, v. 1.2 276 | — Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or 277 | Strong Reciprocity (LiLiQ-R+) 278 | 279 | — The European Commission may update this Appendix to later versions of the 280 | above licences without producing a new version of the EUPL, as long as they 281 | provide the rights granted in Article 2 of this Licence and protect the 282 | covered Source Code from exclusive appropriation. 283 | — All other changes or additions to this Appendix require the production of a 284 | new EUPL version. 285 | -------------------------------------------------------------------------------- /parse_devices.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import logging 4 | import re 5 | import xml.etree.ElementTree as ET 6 | import itertools 7 | import textwrap 8 | import tomli 9 | 10 | from epson_print_conf import get_printer_models, EpsonPrinter 11 | 12 | WASTE_LABELS = [ 13 | "main_waste", "borderless_waste", "third_waste", "fourth_waste", 14 | "fifth_waste", "sixth_waste" 15 | ] 16 | 17 | def to_ranges(iterable): 18 | iterable = sorted(set(iterable)) 19 | for key, group in itertools.groupby(enumerate(iterable), 20 | lambda t: t[1] - t[0]): 21 | group = list(group) 22 | yield group[0][1], group[-1][1] 23 | 24 | 25 | def text_to_bytes(text): 26 | l = [int(h, 16) for h in text.split()] 27 | r = list(to_ranges(l)) 28 | if len(l) > 6 and len(r) == 1: 29 | return eval("range(%s, %s)" % (r[0][0], r[0][1]+1)) 30 | return l 31 | 32 | 33 | def text_to_dict(text): 34 | b = text_to_bytes(text) 35 | return {b[i]: b[i + 1] for i in range(0, len(b), 2)} 36 | 37 | 38 | def traverse_data(element, depth=0): 39 | indent = ' ' * depth 40 | if element.tag and not element.attrib and element.text and element.text.strip(): 41 | print(indent + element.tag + " = " + element.text) 42 | else: 43 | if element.tag: 44 | print(indent + element.tag + ":") 45 | if element.attrib: 46 | print(indent + ' Attributes:', element.attrib) 47 | if element.text and element.text.strip(): 48 | print(indent + ' Text:', element.text.strip()) 49 | 50 | # Recursively traverse the children 51 | for child in element: 52 | traverse_data(child, depth + 1) 53 | 54 | 55 | def generate_config_from_xml( 56 | config, traverse=False, add_fatal_errors=False, full=False, printer_model=False 57 | ): 58 | irc_pattern = [ 59 | r'Ink replacement counter %-% (\w+) % \((\w+)\)', 60 | 'Ink replacement counter %-% (\\w+ \\w+) % \\((\\w+)\\)' 61 | ] 62 | 63 | try: 64 | tree = ET.parse(config) 65 | except Exception as e: 66 | logging.error("Invalid XML file - %s", e) 67 | return {} 68 | root = tree.getroot() 69 | printer_config = {} 70 | for printer in root.iterfind(".//printer"): 71 | title = printer.attrib.get("title", "") 72 | if printer_model and printer_model not in title: 73 | continue 74 | specs = printer.attrib["specs"].split(",") 75 | logging.info( 76 | "Tag: %s, Attributes: %s, Specs: %s", 77 | printer.tag, printer.attrib, printer.attrib['specs'] 78 | ) 79 | printer_short_name = printer.attrib["short"] 80 | printer_long_name = printer.attrib["title"] 81 | printer_model_name = printer.attrib["model"] 82 | chars = {} 83 | for spec in specs: 84 | logging.debug("SPEC: %s", spec) 85 | for elm in root.iterfind(".//" + spec): 86 | if traverse: 87 | traverse_data(elm) 88 | for item in elm: 89 | logging.debug("item.tag: %s", item.tag) 90 | if elm.tag == 'EPSON' and item.tag == "status": 91 | for st in item: 92 | if full and st.tag == 'colors': 93 | chars["ink_color_ids"] = {} 94 | for color in st: 95 | if color.tag == 'color': 96 | color_code = "" 97 | color_name = "" 98 | for color_data in color: 99 | if color_data.tag == "code": 100 | color_code = color_data.text 101 | if color_data.tag == "name": 102 | color_name = color_data.text 103 | chars["ink_color_ids"][color_code] = color_name 104 | if full and st.tag == 'states': 105 | chars["status_ids"] = {} 106 | for status_id in st: 107 | if status_id.tag == 'state': 108 | status_code = "" 109 | status_name = "" 110 | for status_data in status_id: 111 | if status_data.tag == "code": 112 | status_code = status_data.text 113 | if status_data.tag == "text": 114 | status_name = status_data.text 115 | chars["status_ids"][status_code] = status_name 116 | if full and st.tag == 'errors': 117 | chars["errcode_ids"] = {} 118 | for error_id in st: 119 | if error_id.tag == 'error': 120 | error_code = "" 121 | error_name = "" 122 | for error_data in error_id: 123 | if error_data.tag == "code": 124 | error_code = error_data.text 125 | if error_data.tag == "text": 126 | error_name = error_data.text 127 | chars["errcode_ids"][error_code] = error_name 128 | if item.tag == "information": 129 | for info in item: 130 | if info.tag == "report": 131 | chars["stats"] = {} 132 | fatal = [] 133 | irc = "" 134 | for number in info: 135 | if number.tag == "fatals" and add_fatal_errors: 136 | for n in number: 137 | if n.tag == "registers": 138 | for j in text_to_bytes(n.text): 139 | fatal.append(j) 140 | chars["last_printer_fatal_errors"] = ( 141 | fatal 142 | ) 143 | if number.tag in ["number", "period"]: 144 | stat_name = "" 145 | for n in number: 146 | if n.tag == "name": 147 | stat_name = n.text 148 | if ( 149 | n.tag == "registers" 150 | and stat_name 151 | ): 152 | match = False 153 | for ircp in irc_pattern: 154 | match = re.search(ircp, stat_name) 155 | if match: 156 | color = match.group(1) 157 | identifier = f"{match.group(2)}" 158 | if "ink_replacement_counters" not in chars: 159 | chars["ink_replacement_counters"] = {} 160 | if color not in chars["ink_replacement_counters"]: 161 | chars["ink_replacement_counters"][color] = {} 162 | chars["ink_replacement_counters"][color][identifier] = int(n.text, 16) 163 | break 164 | if not match: 165 | stat_name = stat_name.replace("% BL", "- Black") 166 | stat_name = stat_name.replace("% CY", "- Cyan") 167 | stat_name = stat_name.replace("% MG", "- Magenta") 168 | stat_name = stat_name.replace("% YE", "- Yellow") 169 | stat_name = stat_name.replace("%-%", "-") 170 | chars["stats"][stat_name] = text_to_bytes(n.text) 171 | if item.tag == "waste": 172 | for operations in item: 173 | if operations.tag == "reset": 174 | chars["raw_waste_reset"] = text_to_dict( 175 | operations.text 176 | ) 177 | if operations.tag == "query": 178 | count = 0 179 | for counter in operations: 180 | waste = {} 181 | for ncounter in counter: 182 | if ncounter.tag == "entry": 183 | if "oids" in waste: 184 | waste["oids"] += text_to_bytes(ncounter.text) 185 | else: 186 | waste["oids"] = text_to_bytes(ncounter.text) 187 | if ncounter.tag == "max": 188 | waste["divider"] = int(ncounter.text) / 100 189 | if full: 190 | for filter in ncounter: 191 | waste["filter"] = filter.text 192 | if counter.text: 193 | if "oids" in waste: 194 | waste["oids"] += text_to_bytes(counter.text) 195 | else: 196 | waste["oids"] = text_to_bytes(counter.text) 197 | chars[WASTE_LABELS[count]] = waste 198 | count += 1 199 | if item.tag == "serial": 200 | chars["serial_number"] = text_to_bytes(item.text) 201 | if full and item.tag == "headid": 202 | chars["headid"] = text_to_bytes(item.text) 203 | if full and item.tag == "memory": 204 | for mem in item: 205 | if mem.tag == "lower": 206 | chars["memory_lower"] = int(mem.text, 16) 207 | if mem.tag == "upper": 208 | chars["memory_upper"] = int(mem.text, 16) 209 | if item.tag == "service": 210 | for s in item: 211 | if s.tag == "factory": 212 | chars["read_key"] = text_to_bytes(s.text) 213 | if s.tag == "keyword": 214 | chars["write_key"] = ( 215 | "".join( 216 | [ 217 | chr(0 if b == 0 else b - 1) 218 | for b in text_to_bytes(s.text) 219 | ] 220 | ) 221 | ).encode() 222 | if full and s.tag == "sendlen": 223 | chars["sendlen"] = int(s.text, 16) 224 | if full and s.tag == "readlen": 225 | chars["readlen"] = int(s.text, 16) 226 | if full: 227 | chars["long_name"] = printer_long_name 228 | chars["model"] = printer_model_name 229 | printer_config[printer_short_name] = chars 230 | return printer_config 231 | 232 | def normalize_config( 233 | config, 234 | remove_invalid=True, 235 | expand_names=True, 236 | add_alias=True, 237 | aggregate_alias=True, 238 | maint_level=True, 239 | add_same_as=True, 240 | ): 241 | logging.info("Number of configuration entries before removing invalid ones: %s", len(config)) 242 | # Remove printers without write_key or without read_key 243 | if remove_invalid: 244 | for base_key, base_items in config.copy().items(): 245 | if 'write_key' not in base_items: 246 | del config[base_key] 247 | continue 248 | if 'read_key' not in base_items: 249 | del config[base_key] 250 | continue 251 | 252 | # Replace original names with converted names and add printers for all optional names 253 | logging.info("Number of configuration entries before adding optional names: %s", len(config)) 254 | if expand_names: 255 | for key, items in config.copy().items(): 256 | printer_list = get_printer_models(key) 257 | del config[key] 258 | for i in printer_list: 259 | if i in config and config[i] != items: 260 | print("ERROR key", key) 261 | quit() 262 | config[i] = items 263 | 264 | # Add aliases for same printer with different names and remove aliased printers 265 | logging.info("Number of configuration entries before removing aliases: %s", len(config)) 266 | if add_alias: 267 | for base_key, base_items in config.copy().items(): 268 | found = False 269 | for key, items in config.copy().items(): 270 | if not found: 271 | if base_key == key and base_key in config: 272 | found = True 273 | continue 274 | if base_key != key and items == base_items: # different name, same printer 275 | if "alias" not in config[base_key]: 276 | config[base_key]["alias"] = [] 277 | for i in get_printer_models(key): 278 | if i not in config[base_key]["alias"]: 279 | config[base_key]["alias"].append(i) 280 | del config[key] 281 | 282 | # Aggregate aliases 283 | logging.info("Number of configuration entries before aggregating aliases: %s", len(config)) 284 | if aggregate_alias: 285 | for base_key, base_items in config.copy().items(): 286 | found = False 287 | for key, items in config.copy().items(): 288 | if not found: 289 | if base_key == key and base_key in config: 290 | found = True 291 | continue 292 | if base_key != key and equal_dicts(base_items, items, ["alias"]): # everything but the alias is the same 293 | base_items["alias"] = sorted(list(set( 294 | (base_items["alias"] if "alias" in base_items else []) 295 | + (items["alias"] if "alias" in items else []) 296 | + [key] 297 | ))) 298 | del config[key] 299 | 300 | # Add "same-as" for almost same printer (IGNORED_KEYS) with different names 301 | if add_same_as: 302 | IGNORED_KEYS = [ # 'alias' must always be present 303 | ['write_key', 'read_key', 'alias'], 304 | ['write_key', 'read_key', 'alias'] + WASTE_LABELS, 305 | ] 306 | for ignored_keys in IGNORED_KEYS: 307 | same_as_counter = 0 308 | for base_key, base_items in config.copy().items(): 309 | found = False 310 | for key, items in config.copy().items(): 311 | if not found: 312 | if base_key == key and base_key in config: 313 | found = True 314 | continue 315 | if base_key != key and 'same-as' not in base_key: 316 | if equal_dicts(base_items, items, ignored_keys): # everything but the ignored keys is the same 317 | # Rebuild the printer with only the ignored keys, then add the 'same-as' 318 | config[base_key] = {} 319 | for i in ignored_keys: 320 | if i in base_items: 321 | config[base_key][i] = base_items[i] 322 | config[base_key]['same-as'] = key 323 | same_as_counter += 1 324 | logging.info("Number of added 'same-as' entries with %s: %s", ignored_keys, same_as_counter) 325 | 326 | # Aggregate aliases 327 | logging.info("Number of configuration entries before aggregating aliases: %s", len(config)) 328 | if aggregate_alias: 329 | for base_key, base_items in config.copy().items(): 330 | found = False 331 | for key, items in config.copy().items(): 332 | if not found: 333 | if base_key == key and base_key in config: 334 | found = True 335 | continue 336 | if base_key != key and equal_dicts(base_items, items, ["alias"]): # everything but the alias is the same 337 | base_items["alias"] = sorted(list(set( 338 | (base_items["alias"] if "alias" in base_items else []) 339 | + (items["alias"] if "alias" in items else []) 340 | + [key] 341 | ))) 342 | del config[key] 343 | 344 | if maint_level: 345 | for key, items in config.copy().items(): 346 | if "raw_waste_reset" in items: 347 | n = 1 348 | for k, v in items["raw_waste_reset"].items(): 349 | if v == 94: 350 | if "stats" not in items: 351 | items["stats"] = {} 352 | m_key = f"Maintenance required level of {ordinal(n)} waste ink counter" 353 | if m_key in items["stats"]: 354 | print("ERROR key", key, m_key) 355 | quit() 356 | items["stats"][m_key] = [k] 357 | n += 1 358 | 359 | logging.info("Number of obtained configuration entries: %s", len(config)) 360 | return config 361 | 362 | def generate_config_from_toml( 363 | config, printer_model=None, full=False, 364 | 365 | ): 366 | # Generate "read_key" values 367 | def hex_to_bytes(hex_value): 368 | byte1 = (hex_value >> 8) & 0xFF 369 | byte2 = hex_value & 0xFF 370 | return [byte2, byte1] 371 | 372 | # Parse "mem" entries to create "raw_waste_reset" 373 | def parse_mem_entries(mem): 374 | raw_waste_reset = {} 375 | for entry in mem: 376 | addr = entry.get('addr', []) 377 | reset = entry.get('reset', [0] * len(addr)) 378 | 379 | for addr_val, reset_val in zip(addr, reset): 380 | raw_waste_reset[addr_val] = reset_val 381 | 382 | return raw_waste_reset 383 | 384 | try: 385 | with open(config, mode="rb") as fp: 386 | parsed_toml = tomli.load(fp) 387 | except Exception as e: 388 | logging.error("Invalid TOML file - %s", e) 389 | return {} 390 | 391 | output_data = {} 392 | 393 | for e_section_name, e_section_val in parsed_toml.items(): 394 | for section_data in e_section_val: 395 | # Process "models" 396 | if not "models" in section_data or not section_data["models"]: 397 | continue 398 | if printer_model and printer_model not in section_data["models"]: 399 | continue 400 | main_model = section_data["models"].pop(0) 401 | output_data[main_model] = {} 402 | alias = section_data["models"] 403 | if alias: 404 | output_data[main_model]["alias"] = alias 405 | 406 | # Convert rkey and extract mem data 407 | if full and "mem_low" in section_data: 408 | output_data[main_model]["mem_low"] = section_data["mem_low"] 409 | if full and "mem_high" in section_data: 410 | output_data[main_model]["mem_high"] = section_data["mem_high"] 411 | if full and "rlen" in section_data: 412 | output_data[main_model]["rlen"] = section_data["rlen"] 413 | if full and "wlen" in section_data: 414 | output_data[main_model]["wlen"] = section_data["wlen"] 415 | if "rkey" in section_data: 416 | read_key = hex_to_bytes(section_data["rkey"]) 417 | output_data[main_model]["read_key"] = read_key 418 | if "wkey1" in section_data: 419 | output_data[main_model]["write_key"] = section_data["wkey1"].encode() 420 | elif "wkey" in section_data: 421 | output_data[main_model]["write_key"] = "".join( 422 | [ 423 | chr(0 if b == 0 else b - 1) 424 | for b in section_data["wkey"].encode() 425 | ] 426 | ) 427 | 428 | if "mem" in section_data: 429 | raw_waste_reset = parse_mem_entries(section_data["mem"]) 430 | output_data[main_model]["raw_waste_reset"] = raw_waste_reset 431 | 432 | return output_data 433 | 434 | def ordinal(n: int): 435 | if 11 <= (n % 100) <= 13: 436 | suffix = 'th' 437 | else: 438 | suffix = ['th', 'st', 'nd', 'rd', 'th'][min(n % 10, 4)] 439 | return str(n) + suffix 440 | 441 | def equal_dicts(a, b, ignore_keys): 442 | ka = set(a).difference(ignore_keys) 443 | kb = set(b).difference(ignore_keys) 444 | return ka == kb and all(a[k] == b[k] for k in ka) 445 | 446 | def main(): 447 | import argparse 448 | import pickle 449 | 450 | parser = argparse.ArgumentParser( 451 | epilog='Generate printer configuration from devices.xml or from Reinkpy TOML' 452 | ) 453 | parser.add_argument( 454 | '-m', 455 | '--model', 456 | dest='printer_model', 457 | default=False, 458 | action="store", 459 | help='Filter printer model. Example: -m XP-205' 460 | ) 461 | parser.add_argument( 462 | '-T', 463 | '--toml', 464 | dest='toml', 465 | action='store_true', 466 | help='Use the Reinkpy TOML input format instead of XML' 467 | ) 468 | parser.add_argument( 469 | '-l', 470 | '--line', 471 | dest='line_length', 472 | type=int, 473 | help='Set line length of the output (default: 120)', 474 | default=120 475 | ) 476 | parser.add_argument( 477 | '-i', 478 | '--indent', 479 | dest='indent', 480 | action='store_true', 481 | help='Indent output of 4 spaces' 482 | ) 483 | parser.add_argument( 484 | '-d', 485 | '--debug', 486 | dest='debug', 487 | action='store_true', 488 | help='Print debug information' 489 | ) 490 | parser.add_argument( 491 | '-t', 492 | '--traverse', 493 | dest='traverse', 494 | action='store_true', 495 | help='Traverse the XML, dumping content related to the printer model' 496 | ) 497 | parser.add_argument( 498 | '-v', 499 | '--verbose', 500 | dest='verbose', 501 | action='store_true', 502 | help='Print verbose information' 503 | ) 504 | parser.add_argument( 505 | '-f', 506 | '--full', 507 | dest='full', 508 | action='store_true', 509 | help='Generate additional tags' 510 | ) 511 | parser.add_argument( 512 | '-e', 513 | '--errors', 514 | dest='add_fatal_errors', 515 | action='store_true', 516 | help='Add last_printer_fatal_errors' 517 | ) 518 | parser.add_argument( 519 | '-c', 520 | "--config", 521 | dest='config_file', 522 | type=argparse.FileType('r'), 523 | help="use the XML or the Reinkpy TOML configuration file to generate" 524 | " the configuration; default is 'devices.xml', or 'epson.toml'" 525 | " if -T is used", 526 | default=0, 527 | nargs=1, 528 | metavar='CONFIG_FILE' 529 | ) 530 | parser.add_argument( 531 | '-s', 532 | '--default_model', 533 | dest='default_model', 534 | action="store", 535 | help='Default printer model. Example: -s XP-205' 536 | ) 537 | parser.add_argument( 538 | '-a', 539 | '--address', 540 | dest='hostname', 541 | action="store", 542 | help='Default printer host name or IP address. (Example: -a 192.168.1.87)' 543 | ) 544 | parser.add_argument( 545 | '-p', 546 | "--pickle", 547 | dest='pickle', 548 | type=argparse.FileType('wb'), 549 | help="Save a pickle archive for subsequent load by ui.py and epson_print_conf.py", 550 | default=0, 551 | nargs=1, 552 | metavar='PICKLE_FILE' 553 | ) 554 | parser.add_argument( 555 | '-I', 556 | '--keep_invalid', 557 | dest='keep_invalid', 558 | action='store_true', 559 | help='Do not remove printers without write_key or without read_key' 560 | ) 561 | parser.add_argument( 562 | '-N', 563 | '--keep_names', 564 | dest='keep_names', 565 | action='store_true', 566 | help='Do not replace original names with converted names and add printers for all optional names' 567 | ) 568 | parser.add_argument( 569 | '-A', 570 | '--no_alias', 571 | dest='no_alias', 572 | action='store_true', 573 | help='Do not add aliases for same printer with different names and remove aliased printers' 574 | ) 575 | parser.add_argument( 576 | '-G', 577 | '--no_aggregate_alias', 578 | dest='no_aggregate_alias', 579 | action='store_true', 580 | help='Do not aggregate aliases of printers with same configuration' 581 | ) 582 | parser.add_argument( 583 | '-S', 584 | '--no_same_as', 585 | dest='no_same_as', 586 | action='store_true', 587 | help='Do not add "same-as" for similar printers with different names' 588 | ) 589 | parser.add_argument( 590 | '-M', 591 | '--no_maint_level', 592 | dest='no_maint_level', 593 | action='store_true', 594 | help='Do not add "Maintenance required levelas" in "stats"' 595 | ) 596 | args = parser.parse_args() 597 | 598 | if args.debug: 599 | logging.getLogger().setLevel(logging.INFO) 600 | 601 | if args.verbose: 602 | logging.getLogger().setLevel(logging.DEBUG) 603 | 604 | if args.config_file: 605 | args.config_file[0].close() 606 | 607 | if args.config_file: 608 | config = args.config_file[0].name 609 | else: 610 | if args.toml: 611 | config = "epson.toml" 612 | else: 613 | config = "devices.xml" 614 | 615 | if args.toml: 616 | printer_config = generate_config_from_toml( 617 | config=config, 618 | printer_model=args.printer_model, 619 | full=args.full, 620 | ) 621 | else: 622 | printer_config = generate_config_from_xml( 623 | config=config, 624 | traverse=args.traverse, 625 | add_fatal_errors=args.add_fatal_errors, 626 | full=args.full, 627 | printer_model=args.printer_model 628 | ) 629 | if not printer_config: 630 | logging.info("No output generated.") 631 | quit() 632 | normalized_config = normalize_config( 633 | config=printer_config, 634 | remove_invalid=not args.keep_invalid, 635 | expand_names=not args.keep_names, 636 | add_alias=not args.no_alias, 637 | aggregate_alias=not args.no_aggregate_alias, 638 | maint_level=not args.no_maint_level, 639 | add_same_as=not args.no_same_as, 640 | ) 641 | 642 | if args.default_model: 643 | if "internal_data" not in normalized_config: 644 | normalized_config["internal_data"] = {} 645 | normalized_config["internal_data"]["default_model"] = args.default_model 646 | if args.hostname: 647 | if "internal_data" not in normalized_config: 648 | normalized_config["internal_data"] = {} 649 | normalized_config["internal_data"]["hostname"] = args.hostname 650 | if args.pickle: 651 | pickle.dump(normalized_config, args.pickle[0]) # serialize the list 652 | args.pickle[0].close() 653 | ep = EpsonPrinter(conf_dict=normalized_config, replace_conf=True) 654 | logging.info("Number of expanded configuration entries: %s", len(ep.PRINTER_CONFIG)) 655 | quit() 656 | 657 | try: 658 | import black 659 | config_str = "PRINTER_CONFIG = " + repr(normalized_config) 660 | mode = black.Mode(line_length=args.line_length, magic_trailing_comma=False) 661 | dict_str = black.format_str(config_str, mode=mode) 662 | except Exception: 663 | import pprint 664 | dict_str = pprint.pformat(config_str) 665 | if args.indent: 666 | dict_str = textwrap.indent(dict_str, ' ') 667 | print(dict_str) 668 | 669 | if __name__ == "__main__": 670 | try: 671 | main() 672 | except KeyboardInterrupt: 673 | print('\nInterrupted.') 674 | try: 675 | sys.exit(130) 676 | except SystemExit: 677 | os._exit(130) 678 | -------------------------------------------------------------------------------- /SNMP_LIB_PERF_COMP.md: -------------------------------------------------------------------------------- 1 | # Benchmarking Python SNMP Libraries for Epson Printer Communication 2 | 3 | This document compares pure Python SNMP libraries, with the goal of performing unauthenticated, sequential SNMPv1 queries to a single Epson printer. 4 | 5 | The approach to use libraries implemented entirely in Python has the goal to avoid the complexity and overhead of wrappers around native libraries, while maintaining acceptable performance. 6 | 7 | For the use case of this repository, that is interfacing with Epson printers, synchronous SNMP simplifies development and maintenance without compromising performance. 8 | 9 | Compared libraries and architectures: 10 | 11 | - Ilya Etingof’s [etingof/pysnmp](https://github.com/etingof/pysnmp) project (unmaintained) in synchronous mode, 12 | - [pysnmplib](https://github.com/pysnmp/pysnmp) in synchronous mode, 13 | - [pysnmp v5.1](https://github.com/lextudio/pysnmp/) synchronous mode, 14 | - [pysnmp v7.1](https://github.com/lextudio/pysnmp/) asynchronous mode, 15 | - [pysnmp v7.1 with pysnmp-sync-adapter](https://github.com/Ircama/pysnmp-sync-adapter) synchronous wrapper, 16 | - [pysnmp v7.1 with pysnmp-sync-adapter and cluster_varbinds](https://github.com/Ircama/pysnmp-sync-adapter#cluster_varbinds) for highest performances, 17 | - raw socket SNMPv1 implementation (synchronous mode). 18 | - Pure python implementation using the [asn1](https://github.com/andrivet/python-asn1) and the default socket libraries. 19 | - [py-snmp-sync](https://github.com/Ircama/py-snmp-sync) synchronous client implemented over PySNMP. 20 | 21 | The comparison exploits a trivial benchmark that performs 100 SNMPv1 GET requests of the same OID to the same printer, measuring total execution time. The used OID is `1.3.6.1.2.1.25.3.2.1.3.1` (`sysName`). 22 | 23 | Benchmark results of this use case show that the legacy synchronous backend `pysnmp.hlapi.v1arch` from [etingof/pysnmp](https://github.com/etingof/pysnmp) delivers performance comparable to the most efficient asynchronous implementations. 24 | 25 | The current codebase of epson_print_conf still relies on this unmaintained `etingof/pysnmp`, specifically its `v1arch` synchronous HLAPI, which remains performant due to: 26 | 27 | - A streamlined architecture that avoids per-request SNMP engine instantiation 28 | - Minimal overhead in dispatching SNMP requests 29 | 30 | However, `etingof/pysnmp` is not published on PyPI. For PyPI-based distribution and dependency management, a switch to a maintained variant such as [`pysnmp`](https://pypi.org/project/pysnmp) or [`pysnmplib`](https://pypi.org/project/pysnmplib) would be necessary. 31 | 32 | Earlier versions of `pysnmp` supported both synchronous and asynchronous APIs. In contrast, recent versions (v7+) have removed synchronous support in favor of an asyncio-only architecture. While this enables more scalable and resource-efficient SNMP operations, it introduces significant migration complexity for legacy codebases built around blocking SNMPv1 workflows. 33 | 34 | A naïve approach to restoring synchronous behavior, e.g., by wrapping each async call in `asyncio.run()`, leads to severe performance degradation. This pattern repeatedly creates and tears down the asyncio event loop and transport stack, incurring massive overhead. 35 | 36 | To mitigate this, several approaches have been explored: 37 | 38 | * [`pysnmp-sync-adapter`](https://github.com/Ircama/pysnmp-sync-adapter): a lightweight compatibility layer wrapping `pysnmp.hlapi.v1arch.asyncio` and `pysnmp.hlapi.v3arch.asyncio` with blocking equivalents (e.g., `get_cmd_sync`). It reuses the asyncio event loop and transport targets, avoiding per-call overhead and achieving optimal performance while maintaining a synchronous API. 39 | 40 | * [`py-snmp-sync`](https://github.com/Ircama/py-snmp-sync): offers high performance by bypassing the asyncio-based API entirely. Instead, it directly uses the lower-level shared components of `pysnmp` that support both sync and async execution. It implements a custom `SyncUdpTransportTarget` based on raw sockets. However, it currently supports only a specialized form of `get_cmd`, limiting general HLAPI compatibility. 41 | 42 | * A separate low-level implementation using ASN.1 and sockets directly is also tested. This approach shows excellent performance for the `get_cmd` request/response pattern but is significantly more complex to maintain and does not support the full SNMP operation set. 43 | 44 | Each approach offers trade-offs between generality, maintainability, and performance. For applications requiring full HLAPI compatibility with minimal refactoring, `pysnmp-sync-adapter` is a practical and efficient choice. For tightly optimized use cases the raw variants can provide superior throughput. 45 | 46 | Optimal performance is achieved using the `cluster_varbinds` utility from `pysnmp-sync-adapter`, which provides possibly the simplest synchronous interface and includes optimized parallel processing which wraps `asyncio` under the hood. 47 | 48 | --- 49 | 50 | ## Code used for the benchmarks and results 51 | 52 | ### Usage of https://github.com/etingof/pysnmp 53 | 54 | ```python 55 | # Usage of https://github.com/etingof/pysnmp 56 | 57 | # pip uninstall pysnmplib 58 | # pip uninstall pysnmp 59 | # pip install pyasn1==0.4.8 60 | # pip install git+https://github.com/etingof/pysnmp.git@master#egg=pysnmp 61 | 62 | import platform 63 | import time 64 | import sys 65 | from pysnmp.hlapi.v1arch import * 66 | 67 | def snmp_get(*snmp_conf): 68 | for errorIndication, errorStatus, errorIndex, varBinds in getCmd( 69 | *snmp_conf, ('1.3.6.1.2.1.25.3.2.1.3.1', None) 70 | ): 71 | if errorIndication: 72 | return f"Error: {errorIndication}" 73 | elif errorStatus: 74 | return f"{errorStatus.prettyPrint()} at {errorIndex}" 75 | elif len(varBinds) != 1: 76 | return f"varBinds error: {len(varBinds)}" 77 | elif len(list(varBinds[0])) != 2: 78 | return f"varBinds[0] error: {len(list(varBinds[0]))}" 79 | return varBinds[0][1] 80 | 81 | if __name__ == '__main__': 82 | if len(sys.argv) < 2: 83 | print("Usage: python script.py ") 84 | sys.exit(1) 85 | 86 | start_time = time.time() 87 | 88 | snmp_conf = ( 89 | SnmpDispatcher(), 90 | CommunityData('public', mpModel=0), 91 | UdpTransportTarget((sys.argv[1], 161)), 92 | ) 93 | for i in range(100): 94 | response = snmp_get(*snmp_conf) 95 | print(response) 96 | 97 | print("--- %s seconds ---" % (time.time() - start_time)) 98 | # --- 0.8790323734283447 seconds --- 99 | # --- 0.866567850112915 seconds --- 100 | # --- 0.8512802124023438 seconds --- 101 | # --- 0.8214724063873291 seconds --- 102 | ``` 103 | 104 | ### Usage of https://github.com/pysnmp/pysnmp 105 | 106 | ```python 107 | # Usage of https://github.com/pysnmp/pysnmp 108 | 109 | # pip uninstall pysnmp 110 | # pip install pyasn1==0.4.8 111 | # pip install pysnmplib 112 | 113 | # Alternative working library: pip install pysnmp==5.1.0 (https://docs.lextudio.com/snmp/) 114 | 115 | import platform 116 | import time 117 | import sys 118 | from pysnmp.hlapi import * 119 | 120 | def snmp_get(*snmp_conf): 121 | for errorIndication, errorStatus, errorIndex, varBinds in getCmd( 122 | *snmp_conf, ObjectType(ObjectIdentity('1.3.6.1.2.1.25.3.2.1.3.1')) 123 | ): 124 | if errorIndication: 125 | return f"Error: {errorIndication}" 126 | elif errorStatus: 127 | return f"{errorStatus.prettyPrint()} at {errorIndex}" 128 | elif len(varBinds) != 1: 129 | return f"varBinds error: {len(varBinds)}" 130 | elif len(list(varBinds[0])) != 2: 131 | return f"varBinds[0] error: {len(list(varBinds[0]))}" 132 | return varBinds[0][1] 133 | 134 | if __name__ == '__main__': 135 | if len(sys.argv) < 2: 136 | print("Usage: python script.py ") 137 | sys.exit(1) 138 | 139 | start_time = time.time() 140 | 141 | snmp_conf = ( 142 | SnmpEngine(), 143 | CommunityData('public', mpModel=0), 144 | UdpTransportTarget((sys.argv[1], 161)), 145 | ContextData(), 146 | ) 147 | for i in range(100): 148 | response = snmp_get(*snmp_conf) 149 | print(response) 150 | 151 | print("--- %s seconds ---" % (time.time() - start_time)) 152 | 153 | # --- 1.0873637199401855 seconds --- 154 | # --- 1.0969550609588623 seconds --- 155 | ``` 156 | 157 | ### Usage of https://github.com/lextudio/pysnmp 7.1 simulating sync behaviour (inefficient) 158 | 159 | ```python 160 | # Usage of https://github.com/lextudio/pysnmp 7.1 161 | # Simulate sync behaviour via asyncio.run() (extremely inefficient and slow mode) 162 | 163 | # pip uninstall pysnmplib 164 | # pip uninstall pyasn1==0.4.8 165 | # pip uninstall pysnmp # git+https://github.com/etingof/pysnmp.git@master#egg=pysnmp 166 | # pip install pysnmp 167 | 168 | import platform 169 | import asyncio 170 | import sys 171 | import time 172 | from pysnmp.hlapi.v1arch.asyncio import * # synchronous mode is not allowed 173 | 174 | async def snmp_get(community_data, transport_target): 175 | errorIndication, errorStatus, errorIndex, varBinds = await get_cmd( 176 | SnmpDispatcher(), # It cannot be initialized once and then reused! 177 | community_data, 178 | transport_target, 179 | ObjectType(ObjectIdentity('1.3.6.1.2.1.25.3.2.1.3.1')), # Model 180 | lookupMib=False, 181 | lexicographicMode=False, 182 | ) 183 | 184 | if errorIndication: 185 | return f"Error: {errorIndication}" 186 | elif errorStatus: 187 | return f"{errorStatus.prettyPrint()} at {errorIndex}" 188 | elif len(varBinds) != 1: 189 | return f"varBinds error: {len(varBinds)}" 190 | elif len(list(varBinds[0])) != 2: 191 | return f"varBinds[0] error: {len(list(varBinds[0]))}" 192 | return varBinds[0][1] 193 | 194 | if __name__ == '__main__': 195 | if len(sys.argv) < 2: 196 | print("Usage: python script.py ") 197 | sys.exit(1) 198 | 199 | if platform.system()=='Windows': 200 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 201 | 202 | start_time = time.time() 203 | 204 | community_data = CommunityData("public", mpModel=0) 205 | transport_target = asyncio.run( 206 | UdpTransportTarget.create((sys.argv[1], 161)) 207 | ) 208 | for i in range(100): 209 | response = asyncio.run(snmp_get(community_data, transport_target)) 210 | print(response) 211 | 212 | print("--- %s seconds ---" % (time.time() - start_time)) 213 | 214 | # --- 13.862501621246338 seconds --- 215 | # --- 13.586702585220337 seconds --- 216 | # --- 13.565954208374023 seconds --- 217 | ``` 218 | 219 | ### Usage of https://github.com/lextudio/pysnmp in async mode 220 | 221 | ```python 222 | # Usage of https://github.com/lextudio/pysnmp 223 | # Using asyncio.gather() for 100 asynch tasks. Single ObjectType in get_cmd 224 | 225 | # pip uninstall pysnmplib 226 | # pip uninstall pysnmp # git+https://github.com/etingof/pysnmp.git@master#egg=pysnmp 227 | # pip install pysnmp 228 | 229 | import platform 230 | import asyncio 231 | import sys 232 | import time 233 | from pysnmp.hlapi.v1arch.asyncio import * 234 | 235 | async def snmp_get(dispatcher, community_data, transport_target): 236 | errorIndication, errorStatus, errorIndex, varBinds = await get_cmd( 237 | dispatcher, 238 | community_data, 239 | transport_target, 240 | ObjectType(ObjectIdentity('1.3.6.1.2.1.25.3.2.1.3.1')), 241 | lookupMib=False, 242 | lexicographicMode=False, 243 | ) 244 | if errorIndication: 245 | return f"Error: {errorIndication}" 246 | elif errorStatus: 247 | return f"{errorStatus.prettyPrint()} at {errorIndex}" 248 | elif len(varBinds) != 1: 249 | return f"varBinds error: {len(varBinds)}" 250 | elif len(list(varBinds[0])) != 2: 251 | return f"varBinds[0] error: {len(list(varBinds[0]))}" 252 | return varBinds[0][1] 253 | 254 | async def main(target_ip): 255 | # Reuse dispatcher and target 256 | dispatcher = SnmpDispatcher() 257 | community_data = CommunityData("public", mpModel=0) 258 | transport_target = await UdpTransportTarget.create((target_ip, 161)) 259 | 260 | tasks = [ 261 | snmp_get(dispatcher, community_data, transport_target) 262 | for _ in range(100) 263 | ] 264 | results = await asyncio.gather(*tasks) 265 | 266 | for r in results: 267 | print(r) 268 | print("--- %s seconds ---" % (time.time() - start_time)) 269 | 270 | if __name__ == '__main__': 271 | if len(sys.argv) < 2: 272 | print("Usage: python script.py ") 273 | sys.exit(1) 274 | 275 | if platform.system()=='Windows': 276 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 277 | 278 | start_time = time.time() 279 | asyncio.run(main(sys.argv[1])) 280 | 281 | # --- 1.4966695308685303 seconds --- 282 | # --- 1.4908103942871094 seconds --- 283 | # --- 1.4765450954437256 seconds --- 284 | # --- 1.4733057022094727 seconds --- 285 | ``` 286 | 287 | ### Usage of https://github.com/lextudio/pysnmp maximizing performance 288 | 289 | ```python 290 | # Usage of https://github.com/lextudio/pysnmp 291 | # Multiple ObjectType in get_cmd 292 | # Using asyncio.gather() for 10 asynch tasks, each including a PDU of 10 OIDs. 293 | 294 | # pip uninstall pysnmplib 295 | # pip uninstall pysnmp # git+https://github.com/etingof/pysnmp.git@master#egg=pysnmp 296 | # pip install pysnmp 297 | 298 | import platform 299 | import asyncio 300 | import sys 301 | import time 302 | from pysnmp.hlapi.v3arch.asyncio import * 303 | 304 | async def snmp_get(dispatcher, community_data, transport_target): 305 | object_types = [ 306 | ObjectType( 307 | ObjectIdentity('1.3.6.1.2.1.25.3.2.1.3.1') 308 | ) for _ in range(10) 309 | ] 310 | """ 311 | Note: we cannot put too many data into a single PDU due to a protocol-level 312 | limit, otherwise we get the SNMP error "tooBig" like "tooBig at 54", that 313 | indicates that the SNMP response PDU exceeds the maximum size supported by the 314 | agent or the transport (typically 484 bytes by default for UDP). For this 315 | reason, to get the 100 queries benckmark, we build a taks of 10 requests, each 316 | including 10 queries. 317 | """ 318 | 319 | errorIndication, errorStatus, errorIndex, varBinds = await get_cmd( 320 | dispatcher, 321 | community_data, 322 | transport_target, 323 | ContextData(), 324 | *object_types, 325 | lookupMib=False 326 | ) 327 | 328 | if errorIndication: 329 | return [f"Error: {errorIndication}"] 330 | elif errorStatus: 331 | return [f"{errorStatus.prettyPrint()} at {errorIndex}"] 332 | 333 | return [val.prettyPrint() for _, val in varBinds] 334 | 335 | async def main(target_ip): 336 | dispatcher = SnmpEngine() 337 | community_data = CommunityData("public", mpModel=0) 338 | transport_target = await UdpTransportTarget.create((target_ip, 161)) 339 | 340 | tasks = [ 341 | snmp_get(dispatcher, community_data, transport_target) 342 | for _ in range(10) 343 | ] 344 | results = await asyncio.gather(*tasks) 345 | 346 | for r in results: 347 | for i in r: 348 | print(i) 349 | 350 | print("--- %s seconds ---" % (time.time() - start_time)) 351 | 352 | if __name__ == '__main__': 353 | if len(sys.argv) < 2: 354 | print("Usage: python script.py ") 355 | sys.exit(1) 356 | 357 | if platform.system() == 'Windows': 358 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 359 | 360 | start_time = time.time() 361 | asyncio.run(main(sys.argv[1])) 362 | 363 | # --- 0.47261977195739746 seconds --- 364 | # --- 0.47225403785705566 seconds --- 365 | # --- 0.4908156394958496 seconds --- 366 | ``` 367 | 368 | ### Usage of https://github.com/Ircama/pysnmp-sync-adapter 369 | 370 | ```python 371 | # pip uninstall pysnmplib 372 | # pip install pysnmp-sync-adapter 373 | 374 | import sys 375 | import time 376 | import asyncio 377 | import platform 378 | from pysnmp.hlapi.v1arch.asyncio import * 379 | from pysnmp_sync_adapter import ( 380 | get_cmd_sync, next_cmd_sync, set_cmd_sync, bulk_cmd_sync, 381 | walk_cmd_sync, bulk_walk_cmd_sync, create_transport 382 | ) 383 | 384 | def main(): 385 | if len(sys.argv) < 2: 386 | print("Usage: python script.py ") 387 | sys.exit(1) 388 | 389 | if platform.system()=='Windows': 390 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 391 | 392 | host = sys.argv[1] 393 | oid_str = '1.3.6.1.2.1.25.3.2.1.3.1' 394 | community = 'public' 395 | 396 | # Pre-create the engine once 397 | dispatcher = SnmpDispatcher() 398 | 399 | # Pre-create the transport once 400 | transport = create_transport(UdpTransportTarget, (host, 161), timeout=1) 401 | 402 | # Pre-create oid and CommunityData once 403 | auth_data = CommunityData(community, mpModel=0) 404 | oid_t = ObjectType(ObjectIdentity(oid_str)) 405 | 406 | start = time.time() 407 | for _ in range(100): 408 | try: 409 | error_ind, error_status, error_index, var_binds = get_cmd_sync( 410 | dispatcher, 411 | auth_data, 412 | transport, 413 | oid_t 414 | ) 415 | if error_ind: 416 | raise RuntimeError(f"SNMP error: {error_ind}") 417 | elif error_status: 418 | raise RuntimeError( 419 | f'{error_status.prettyPrint()} at {error_index and var_binds[int(error_index) - 1][0] or "?"}' 420 | ) 421 | else: 422 | for oid, val in var_binds: 423 | print(val.prettyPrint()) 424 | except Exception as e: 425 | print("Request failed:", e) 426 | 427 | print(f"--- {time.time() - start:.3f} seconds ---") 428 | 429 | if __name__ == '__main__': 430 | main() 431 | 432 | # --- 1.217 seconds --- 433 | # --- 1.290 seconds --- 434 | # --- 1.234 seconds --- 435 | ``` 436 | 437 | ### https://github.com/Ircama/pysnmp-sync-adapter#cluster_varbinds over PySNMP. 438 | 439 | This simple approach offers the best performances among all tests. 440 | 441 | ```python 442 | # pip uninstall pysnmplib 443 | # pip install pysnmp-sync-adapter 444 | 445 | import sys 446 | import time 447 | import asyncio 448 | import platform 449 | from pysnmp.hlapi.v1arch.asyncio import * 450 | from pysnmp_sync_adapter import ( 451 | parallel_get_sync, create_transport, cluster_varbinds 452 | ) 453 | 454 | def main(): 455 | if len(sys.argv) < 2: 456 | print("Usage: python script.py ") 457 | sys.exit(1) 458 | 459 | if platform.system()=='Windows': 460 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 461 | 462 | host = sys.argv[1] 463 | oid_str = '1.3.6.1.2.1.25.3.2.1.3.1' 464 | community = 'public' 465 | 466 | # Pre-create the engine once 467 | dispatcher = SnmpDispatcher() 468 | 469 | # Pre-create the transport once 470 | transport = create_transport(UdpTransportTarget, (host, 161), timeout=1) 471 | 472 | # Pre-create CommunityData once 473 | auth_data = CommunityData(community, mpModel=0) 474 | oid_t = ObjectType(ObjectIdentity(oid_str)) 475 | 476 | # Create 100 queries using optimized PDU composition 477 | wrapped_queries = [ObjectType(ObjectIdentity(oid_str)) for _ in range(100)] 478 | wrapped_queries = cluster_varbinds(wrapped_queries, max_per_pdu=10) 479 | 480 | start = time.time() 481 | for error_ind, error_status, error_index, var_binds in parallel_get_sync( 482 | dispatcher, 483 | auth_data, 484 | transport, 485 | queries=wrapped_queries 486 | ): 487 | if error_ind: 488 | print(f"SNMP error: {error_ind}") 489 | quit() 490 | elif error_status: 491 | print(f'{error_status.prettyPrint()} at {error_index and var_binds[int(error_index) - 1][0] or "?"}') 492 | quit() 493 | else: 494 | for oid, val in var_binds: 495 | print(val.prettyPrint()) 496 | 497 | print(f"--- {time.time() - start:.3f} seconds ---") 498 | 499 | if __name__ == '__main__': 500 | main() 501 | 502 | # --- 0.410 seconds --- 503 | # --- 0.424 seconds --- 504 | # --- 0.360 seconds --- 505 | # --- 0.423 seconds --- 506 | ``` 507 | 508 | ### Usage of the oneliner package, being deprecated in newer versions of pysnmp 509 | 510 | ```python 511 | # Usage of the oneliner package, being deprecated in newer versions of pysnmp 512 | 513 | # pip uninstall pysnmp 514 | # pip uninstall pysnmplib 515 | # pip install pysnmp==5.1.0 516 | # pip install pyasn1==0.4.8 517 | 518 | import platform 519 | import time 520 | import sys 521 | from pysnmp.entity.rfc3413.oneliner import cmdgen 522 | 523 | if len(sys.argv) < 2: 524 | print("Usage: python script.py ") 525 | sys.exit(1) 526 | 527 | host = sys.argv[1] 528 | 529 | # Create these objects outside the loop for better performance 530 | cmd_gen = cmdgen.CommandGenerator() 531 | transport = cmdgen.UdpTransportTarget((host, 161), timeout=5, retries=1) 532 | oid = '1.3.6.1.2.1.25.3.2.1.3.1' 533 | oid_tuple = tuple(int(part) for part in oid.split('.')) 534 | comm_data = cmdgen.CommunityData('public', mpModel=0) 535 | 536 | start_time = time.time() 537 | 538 | for i in range(100): 539 | # Execute the command directly in the loop for better performance 540 | error_indication, error_status, error_index, var_binds = cmd_gen.getCmd( 541 | comm_data, 542 | transport, 543 | oid_tuple 544 | ) 545 | 546 | if error_indication: 547 | print(f"Error: {error_indication}") 548 | elif error_status: 549 | print(f"{error_status.prettyPrint()} at {error_index}") 550 | elif len(var_binds) != 1: 551 | print(f"varBinds error: {len(var_binds)}") 552 | else: 553 | print(var_binds[0][1]) 554 | 555 | print("--- %s seconds ---" % (time.time() - start_time)) 556 | 557 | # --- 1.1897211074829102 seconds --- 558 | # --- 1.0130093097686768 seconds --- 559 | ``` 560 | 561 | ### Raw Python implementation of the SNMPv1 protocol 562 | 563 | ```python 564 | # Pure Python implementation of the SNMPv1 basic GetRequest/GetResponse 565 | # protocol with the sole usage of the default socket library. 566 | 567 | # Code Complexity: Very High 568 | # Performance: excellent 569 | # Protocol Compliance: manual 570 | # Maintenance: Error-Prone 571 | 572 | import socket 573 | import time 574 | import sys 575 | 576 | def encode_length(length): 577 | if length < 0x80: 578 | return bytes([length]) 579 | else: 580 | num_bytes = (length.bit_length() + 7) // 8 581 | encoded = [] 582 | for _ in range(num_bytes): 583 | encoded.append(length & 0xff) 584 | length >>= 8 585 | encoded = bytes(encoded[::-1]) 586 | return bytes([0x80 | num_bytes]) + encoded 587 | 588 | def encode_base128(n): 589 | if n == 0: 590 | return bytes([0]) 591 | bytes_list = [] 592 | while n > 0: 593 | bytes_list.insert(0, n & 0x7f) 594 | n >>= 7 595 | for i in range(len(bytes_list) - 1): 596 | bytes_list[i] |= 0x80 597 | return bytes(bytes_list) 598 | 599 | def encode_oid(oid_str): 600 | parts = list(map(int, oid_str.split('.'))) 601 | if len(parts) < 2: 602 | raise ValueError("OID must have at least two components") 603 | first = parts[0] * 40 + parts[1] 604 | encoded = bytes([first]) 605 | for n in parts[2:]: 606 | encoded += encode_base128(n) 607 | return b'\x06' + encode_length(len(encoded)) + encoded 608 | 609 | def encode_integer(value): 610 | if value == 0: 611 | return b'\x02\x01\x00' 612 | byte_count = (value.bit_length() + 7) // 8 613 | bytes_val = value.to_bytes(byte_count, 'big', signed=False) 614 | return b'\x02' + encode_length(len(bytes_val)) + bytes_val 615 | 616 | def construct_snmp_get_request(oid, community='public', request_id=1): 617 | version = b'\x02\x01\x00' 618 | community_enc = b'\x04' + encode_length(len(community)) + community.encode() 619 | oid_enc = encode_oid(oid) 620 | null = b'\x05\x00' 621 | var_bind = b'\x30' + encode_length(len(oid_enc) + len(null)) + oid_enc + null 622 | var_bindings = b'\x30' + encode_length(len(var_bind)) + var_bind 623 | pdu_content = ( 624 | encode_integer(request_id) + 625 | encode_integer(0) + 626 | encode_integer(0) + 627 | var_bindings 628 | ) 629 | pdu = b'\xa0' + encode_length(len(pdu_content)) + pdu_content 630 | snmp_message = ( 631 | b'\x30' 632 | + encode_length(len(version) + len(community_enc) + len(pdu)) 633 | + version 634 | + community_enc 635 | + pdu 636 | ) 637 | return snmp_message 638 | 639 | def parse_snmp_response(data): 640 | def parse_length(data, index): 641 | length_byte = data[index] 642 | index += 1 643 | if length_byte < 0x80: 644 | return (length_byte, index) 645 | else: 646 | num_bytes = length_byte & 0x7f 647 | length = 0 648 | for _ in range(num_bytes): 649 | length = (length << 8) | data[index] 650 | index += 1 651 | return (length, index) 652 | 653 | index = 0 654 | if data[index] != 0x30: 655 | raise ValueError("Expected SEQUENCE") 656 | index += 1 657 | length, index = parse_length(data, index) 658 | if data[index] != 0x02: 659 | raise ValueError("Expected version INTEGER") 660 | index += 1 661 | version_length, index = parse_length(data, index) 662 | index += version_length 663 | if data[index] != 0x04: 664 | raise ValueError("Expected community OCTET STRING") 665 | index += 1 666 | community_length, index = parse_length(data, index) 667 | index += community_length 668 | if data[index] != 0xa2: 669 | raise ValueError("Expected GetResponse PDU") 670 | index += 1 671 | pdu_length, index = parse_length(data, index) 672 | pdu_data = data[index:index+pdu_length] 673 | index += pdu_length 674 | pdu_index = 0 675 | if pdu_data[pdu_index] != 0x02: 676 | raise ValueError("Expected request-id INTEGER") 677 | pdu_index += 1 678 | req_id_len, pdu_index = parse_length(pdu_data, pdu_index) 679 | pdu_index += req_id_len 680 | if pdu_data[pdu_index] != 0x02: 681 | raise ValueError("Expected error-status INTEGER") 682 | pdu_index += 1 683 | err_status_len, pdu_index = parse_length(pdu_data, pdu_index) 684 | pdu_index += err_status_len 685 | if pdu_data[pdu_index] != 0x02: 686 | raise ValueError("Expected error-index INTEGER") 687 | pdu_index += 1 688 | err_index_len, pdu_index = parse_length(pdu_data, pdu_index) 689 | pdu_index += err_index_len 690 | if pdu_data[pdu_index] != 0x30: 691 | raise ValueError("Expected variable-bindings SEQUENCE") 692 | pdu_index += 1 693 | var_bindings_len, pdu_index = parse_length(pdu_data, pdu_index) 694 | var_bindings = pdu_data[pdu_index:pdu_index+var_bindings_len] 695 | pdu_index += var_bindings_len 696 | var_index = 0 697 | if var_bindings[var_index] != 0x30: 698 | raise ValueError("Expected variable-binding entry SEQUENCE") 699 | var_index += 1 700 | entry_len, var_index = parse_length(var_bindings, var_index) 701 | entry_data = var_bindings[var_index:var_index+entry_len] 702 | var_index += entry_len 703 | entry_idx = 0 704 | if entry_data[entry_idx] != 0x06: 705 | raise ValueError("Expected OID") 706 | entry_idx += 1 707 | oid_len, entry_idx = parse_length(entry_data, entry_idx) 708 | entry_idx += oid_len 709 | value_tag = entry_data[entry_idx] 710 | entry_idx += 1 711 | value_len, entry_idx = parse_length(entry_data, entry_idx) 712 | value_bytes = entry_data[entry_idx:entry_idx+value_len] 713 | if value_tag == 0x04: 714 | return value_bytes.decode() 715 | elif value_tag == 0x02: 716 | return int.from_bytes(value_bytes, 'big', signed=True) 717 | else: 718 | return value_bytes 719 | 720 | def main(): 721 | if len(sys.argv) < 2: 722 | print("Usage: python script.py ") 723 | sys.exit(1) 724 | host = sys.argv[1] 725 | oid = '1.3.6.1.2.1.25.3.2.1.3.1' 726 | request_id = 1 727 | start_time = time.time() 728 | for _ in range(100): 729 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 730 | sock.settimeout(1) 731 | request = construct_snmp_get_request(oid, 'public', request_id) 732 | try: 733 | sock.sendto(request, (host, 161)) 734 | response, _ = sock.recvfrom(65536) 735 | value = parse_snmp_response(response) 736 | print(value) 737 | except Exception as e: 738 | print(f"Error: {e}") 739 | finally: 740 | sock.close() 741 | request_id += 1 742 | print("--- %s seconds ---" % (time.time() - start_time)) 743 | 744 | def main_performance(): 745 | if len(sys.argv) < 2: 746 | print("Usage: python script.py ") 747 | sys.exit(1) 748 | 749 | host = sys.argv[1] 750 | oid = '1.3.6.1.2.1.25.3.2.1.3.1' 751 | request_id = 1 752 | start_time = time.time() 753 | 754 | # Create socket once and reuse it 755 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 756 | sock.settimeout(1) # Timeout applies to all operations 757 | 758 | try: 759 | for _ in range(100): 760 | request = construct_snmp_get_request(oid, 'public', request_id) 761 | try: 762 | # Send and receive using the same socket 763 | sock.sendto(request, (host, 161)) 764 | response, _ = sock.recvfrom(65536) 765 | value = parse_snmp_response(response) 766 | print(value) 767 | except socket.timeout: 768 | print("Error: Request timed out") 769 | except Exception as e: 770 | print(f"Error: {e}") 771 | finally: 772 | request_id += 1 773 | finally: 774 | sock.close() # Close socket once at the end 775 | 776 | print("--- %s seconds ---" % (time.time() - start_time)) 777 | 778 | if __name__ == '__main__': 779 | main_performance() 780 | # --- 0.7888898849487305 seconds --- 781 | # --- 0.7544982433319092 seconds --- 782 | # --- 0.7131996154785156 seconds --- 783 | ``` 784 | 785 | ### Pure python implementation using the asn1 and the default socket libraries. 786 | 787 | ```python 788 | # Pure python implementation using the asn1 and the default socket libraries. 789 | 790 | # pip install asn1 791 | 792 | # Code Complexity: low 793 | # Performance: excellent 794 | # Protocol Compliance: decent 795 | # Maintenance: decent 796 | 797 | import socket 798 | import time 799 | import sys 800 | import logging 801 | import asn1 802 | 803 | def main_performance(): 804 | if len(sys.argv) < 2: 805 | print("Usage: python script.py ") 806 | sys.exit(1) 807 | 808 | host = sys.argv[1] 809 | oid = '1.3.6.1.2.1.25.3.2.1.3.1' 810 | request_id = 1 811 | start_time = time.time() 812 | 813 | # Create and reuse the socket 814 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 815 | sock.settimeout(1) 816 | 817 | try: 818 | for _ in range(100): 819 | # Encode the SNMP GetRequest using asn1 library 820 | encoder = asn1.Encoder() 821 | encoder.start() 822 | 823 | # Main SNMP message sequence 824 | encoder.enter(asn1.Numbers.Sequence) 825 | encoder.write(0, asn1.Numbers.Integer) # SNMP version 0 (v1) 826 | encoder.write(b'public', asn1.Numbers.OctetString) # community 827 | 828 | # GetRequest PDU (context-specific tag 0) 829 | encoder.enter(0, cls=asn1.Classes.Context) # Generate 0xA0 830 | encoder.write(request_id, asn1.Numbers.Integer) 831 | encoder.write(0, asn1.Numbers.Integer) # error-status 832 | encoder.write(0, asn1.Numbers.Integer) # error-index 833 | 834 | # Variable bindings sequence 835 | encoder.enter(asn1.Numbers.Sequence) 836 | encoder.enter(asn1.Numbers.Sequence) # Single var-bind 837 | encoder.write(oid, asn1.Numbers.ObjectIdentifier) # OID 838 | encoder.write(None, asn1.Numbers.Null) # Null value 839 | encoder.leave() # Exit var-bind 840 | encoder.leave() # Exit variable bindings 841 | 842 | encoder.leave() # Exit PDU 843 | encoder.leave() # Exit main sequence 844 | 845 | request = encoder.output() 846 | logging.debug("REQ: %s", request.hex(' ')) 847 | 848 | # Send request 849 | sock.sendto(request, (host, 161)) 850 | 851 | try: 852 | # Receive and decode response 853 | response, _ = sock.recvfrom(65536) 854 | logging.debug("RES: %s", response.hex(' ')) 855 | decoder = asn1.Decoder() 856 | decoder.start(response) 857 | #_, value = decoder.read(); print("DECODED", value); continue 858 | 859 | # Decode top-level sequence 860 | decoder.enter() 861 | _, version = decoder.read() 862 | _, community = decoder.read() 863 | 864 | # Verify GetResponse PDU (context-specific tag 2) 865 | tag = decoder.peek() 866 | if tag.cls != asn1.Classes.Context or tag.nr != 2: # if decoder.peek().nr != 0xA2: 867 | raise ValueError("Expected GetResponse PDU") 868 | decoder.enter() # Enter PDU content 869 | 870 | # Read response fields 871 | _, resp_id = decoder.read() 872 | _, error_status = decoder.read() 873 | _, error_index = decoder.read() 874 | logging.debug( 875 | "version: %s, community: %s, resp_id: %s," 876 | " error_status: %s, error_index: %s", 877 | version, community, resp_id, error_status, error_index 878 | ) 879 | 880 | # Process variable bindings 881 | decoder.enter() 882 | decoder.enter() # var-bind sequence 883 | _, resp_oid = decoder.read() 884 | value_type, value = decoder.read() 885 | 886 | # Handle different value types 887 | if value_type == asn1.Numbers.OctetString: 888 | decoded_value = value.decode('utf-8') 889 | elif value_type == asn1.Numbers.Integer: 890 | decoded_value = value 891 | else: 892 | decoded_value = value # Fallback to raw bytes 893 | 894 | print("decoded_value:", decoded_value) 895 | 896 | except (asn1.Error, ValueError) as e: 897 | print(f"Decoding error: {e}") 898 | 899 | request_id += 1 900 | 901 | finally: 902 | sock.close() 903 | 904 | print(f"--- {time.time() - start_time} seconds ---") 905 | 906 | if __name__ == '__main__': 907 | main_performance() 908 | 909 | # --- 1.0287363529205322 seconds --- 910 | # --- 0.937241792678833 seconds --- 911 | # --- 1.0431180000305176 seconds --- 912 | ``` 913 | 914 | ### https://github.com/Ircama/py-snmp-sync over PySNMP. 915 | 916 | ```python 917 | # pip uninstall pysnmplib 918 | # pip install py-snmp-sync 919 | 920 | import sys 921 | import time 922 | from py_snmp_sync import ( 923 | SyncUdpTransportTarget, sync_get_cmd, ObjectIdentity, CommunityData 924 | ) 925 | 926 | def main(): 927 | if len(sys.argv) < 2: 928 | print("Usage: python script.py ") 929 | sys.exit(1) 930 | 931 | host = sys.argv[1] 932 | oid_str = '1.3.6.1.2.1.25.3.2.1.3.1' 933 | community = 'public' 934 | 935 | # Pre-create the transport once 936 | target = SyncUdpTransportTarget((host, 161)) 937 | 938 | # Pre-create oid and CommunityData once 939 | auth_data = CommunityData(community, mpModel=0) 940 | oid = ObjectIdentity(oid_str) 941 | 942 | start = time.time() 943 | for _ in range(100): 944 | try: 945 | error_ind, error_status, error_index, var_binds = sync_get_cmd( 946 | CommunityData("public", mpModel=0), 947 | target, 948 | oid 949 | ) 950 | if error_ind: 951 | raise RuntimeError(f"SNMP error: {error_ind}") 952 | elif error_status: 953 | raise RuntimeError( 954 | f'{error_status.prettyPrint()} at {error_index and var_binds[int(error_index) - 1][0] or "?"}' 955 | ) 956 | else: 957 | for _, val in var_binds: 958 | print(val.prettyPrint()) 959 | except Exception as e: 960 | print("Request failed:", e) 961 | 962 | print(f"--- {time.time() - start:.3f} seconds ---") 963 | 964 | if __name__ == '__main__': 965 | main() 966 | 967 | # --- 1.125 seconds --- 968 | # --- 1.197 seconds --- 969 | # --- 1.145 seconds --- 970 | ``` 971 | 972 | -------------------------------------------------------------------------------- 973 | 974 | ## EPSON SNMP Protocol analysis 975 | 976 | The EPSON printer uses **SNMPv1** with basic read-only community string authentication and no security features. It implements standard and proprietary MIBs. 977 | 978 | - Simple Network Management Protocol Version 1. 979 | - No encryption (SNMPv1 has no security beyond community strings). 980 | - Community string: `public` sent in cleartext (default for read-only access in SNMPv1). 981 | - UDP port 161 (defaultfor SNMP agents). Connectionless, lightweight communication. 982 | 983 | ### Full decoding of the SNMP request 984 | 985 | Full decoding of the SNMP request for the OID `1.3.6.1.2.1.25.3.2.1.3.1`. 986 | 987 | #### Raw Bytes of the Request (hex): 988 | 989 | ``` 990 | 30 29 02 01 00 04 06 991 | 70 75 62 6c 69 63 [Public] 992 | a0 1c 02 01 26 02 01 00 02 01 00 30 11 30 0f 06 0b 993 | 2b 06 01 02 01 19 03 02 01 03 01 [1.3.6.1.2.1.25.3.2.1.3.1] 994 | 05 00 995 | 996 | 0) 30 29 997 | 2) 02 01 00 998 | 5) 04 06 70 75 62 6c 69 63 [Public] 999 | 11) a0 1c 1000 | 13) 02 01 26 1001 | 16) 02 01 00 1002 | 19) 02 01 00 1003 | 22) 30 11 1004 | 24) 30 0f 1005 | 26) 06 0b 2b 06 01 02 01 19 03 02 01 03 01 [1.3.6.1.2.1.25.3.2.1.3.1] 1006 | 39) 05 00 1007 | ``` 1008 | 1009 | String |Hex representation 1010 | --------------------------|----------------------------------- 1011 | Public | 70 75 62 6c 69 63 1012 | 1.3.6.1.2.1.25.3.2.1.3.1 | 2b 06 01 02 01 19 03 02 01 03 01 1013 | 1014 | SNMPv1 PDU Tags: 1015 | - 0xA0: GetRequest 1016 | - 0xA1: GetNextRequest 1017 | - 0xA2: GetResponse 1018 | - 0xA3: SetRequest 1019 | - 0xA4: Trap 1020 | 1021 | ``` 1022 | SNMP Message (SEQUENCE, 41 bytes) 1023 | ├─ Version (INTEGER): 0 (SNMPv1) 1024 | ├─ Community (OCTET STRING): "public" 1025 | └─ GetRequest-PDU (0xA0, 28 bytes = 0x1C) 1026 | ├─ Request-ID (INTEGER): 38 (0x26) 1027 | ├─ Error-Status (INTEGER): 0 (noError) 1028 | ├─ Error-Index (INTEGER): 0 1029 | └─ Variable-Bindings (SEQUENCE, 17 bytes = 0x11) 1030 | └─ VarBind (SEQUENCE, 15 bytes = 0x0f) 1031 | ├─ OID (OBJECT IDENTIFIER): 1.3.6.1.2.1.25.3.2.1.3.1 1032 | └─ Value (NULL) (no value) 1033 | ``` 1034 | 1035 | #### Request Breakdown (SNMPv1 Structure): 1036 | 1037 | 1. **SNMP Message (SEQUENCE)**: `30 29` 1038 | - Tag: `0x30` (SEQUENCE) 1039 | - Length: `0x29` (41 bytes total for the entire message). 1040 | 1041 | 2. **SNMP Version (INTEGER)**: `02 01 00` 1042 | - Tag: `0x02` (INTEGER) 1043 | - Length: `0x01` (1 byte) 1044 | - Value: `0x00` (SNMPv1). 1045 | 1046 | 3. **Community String (OCTET STRING)**: `04 06 70 75 62 6c 69 63` 1047 | - Tag: `0x04` (OCTET STRING) 1048 | - Length: `0x06` (6 bytes) 1049 | - Value: `70 75 62 6c 69 63` ("public" in ASCII). 1050 | 1051 | 4. **GetRequest-PDU**: `a0 1c` 1052 | - Tag: `0xA0` (SNMPv1 GetRequest) 1053 | - Length: `0x1C` (28 bytes for the PDU contents). 1054 | 1055 | 5. **Request-ID (INTEGER)**: `02 01 26` 1056 | - Tag: `0x02` (INTEGER) 1057 | - Length: `0x01` (1 byte) 1058 | - Value: `0x26` (request ID = 38). 1059 | 1060 | 6. **Error-Status (INTEGER)**: `02 01 00` 1061 | - Tag: `0x02` (INTEGER) 1062 | - Length: `0x01` (1 byte) 1063 | - Value: `0x00` (noError). 1064 | 1065 | 7. **Error-Index (INTEGER)**: `02 01 00` 1066 | - Tag: `0x02` (INTEGER) 1067 | - Length: `0x01` (1 byte) 1068 | - Value: `0x00` (no error index). 1069 | 1070 | 8. **Variable-Bindings (SEQUENCE)**: `30 11` 1071 | - Tag: `0x30` (SEQUENCE) 1072 | - Length: `0x11` (17 bytes). 1073 | 1074 | - **VarBind Entry (SEQUENCE)**: `30 0f` 1075 | - Tag: `0x30` (SEQUENCE) 1076 | - Length: `0x0f` (15 bytes). 1077 | 1078 | - **OID (OBJECT IDENTIFIER)**: `06 0b 2b 06 01 02 01 19 03 02 01 03 01` 1079 | - Tag: `0x06` (OID) 1080 | - Length: `0x0B` (11 bytes) 1081 | - Encoded OID: `2b 06 01 02 01 19 03 02 01 03 01` 1082 | - Decoded: `1.3.6.1.2.1.25.3.2.1.3.1` (matches your target OID). 1083 | 1084 | - **Value (NULL)**: `05 00` 1085 | - Tag: `0x05` (NULL) 1086 | - Length: `0x00` (no value). 1087 | 1088 | ### Full decoding of the SNMPv1 response 1089 | 1090 | Full decoding of the SNMP response for the OID `1.3.6.1.2.1.25.3.2.1.3.1` returning `EPSON XP-205 207 Series`. 1091 | 1092 | #### Raw Bytes of the Response (hex): 1093 | 1094 | ```plaintext 1095 | 30 40 02 01 00 04 06 1096 | 70 75 62 6c 69 63 [Public] 1097 | a2 33 02 01 01 02 01 00 02 01 00 30 28 30 26 06 0b 1098 | 2b 06 01 02 01 19 03 02 01 03 01 [1.3.6.1.2.1.25.3.2.1.3.1], 11 bytes 1099 | 04 17 1100 | 45 50 53 4f 4e 20 58 50 2d 32 30 35 20 32 30 37 20 53 65 72 69 65 73 [EPSON XP-205 207 Series], 23 bytes 1101 | 1102 | 0) 30 40 1103 | 2) 02 01 00 1104 | 5) 04 06 70 75 62 6c 69 63 [Public] 1105 | 13) a2 33 1106 | 15) 02 01 01 1107 | 18) 02 01 00 1108 | 21) 02 01 00 1109 | 24) 30 28 1110 | 26) 30 26 1111 | 28) 06 0b 2b 06 01 02 01 19 03 02 01 03 01 [1.3.6.1.2.1.25.3.2.1.3.1], 13 bytes 1112 | 41) 04 17 45 50 53 4f 4e 20 58 50 2d 32 30 35 20 32 30 37 20 53 65 72 69 65 73 [EPSON XP-205 207 Series], 25 bytes 1113 | ``` 1114 | 1115 | String |Hex representation 1116 | --------------------------|----------------------------------- 1117 | Public | 70 75 62 6c 69 63 1118 | 1.3.6.1.2.1.25.3.2.1.3.1 | 2b 06 01 02 01 19 03 02 01 03 01 1119 | EPSON XP-205 207 Series | 45 50 53 4f 4e 20 58 50 2d 32 30 35 20 32 30 37 20 53 65 72 69 65 73 1120 | 1121 | SNMPv1 PDU Tags: 1122 | - 0xA0: GetRequest 1123 | - 0xA1: GetNextRequest 1124 | - 0xA2: GetResponse 1125 | - 0xA3: SetRequest 1126 | - 0xA4: Trap 1127 | 1128 | ``` 1129 | SNMP Message (SEQUENCE, 64 bytes) 1130 | ├─ Version (INTEGER): 0 (SNMPv1) 1131 | ├─ Community (OCTET STRING): "public" 1132 | └─ GetResponse-PDU (0xA2, 51 bytes) 1133 | ├─ Request-ID (INTEGER): 100 1134 | ├─ Error-Status (INTEGER): 0 (noError) 1135 | ├─ Error-Index (INTEGER): 0 1136 | └─ Variable-Bindings (SEQUENCE, 40 bytes = 0x28) 1137 | └─ VarBind (SEQUENCE, 38 bytes = 0x26) 1138 | ├─ OID (OBJECT IDENTIFIER): 1.3.6.1.2.1.25.3.2.1.3.1 1139 | └─ Value (OCTET STRING): "EPSON XP-205 207 Series" 1140 | ``` 1141 | 1142 | #### Response Breakdown (SNMPv1 Structure): 1143 | 1144 | 1. **SNMP Message Header**: 1145 | - **Tag**: `0x30` (SEQUENCE) 1146 | - **Length**: `0x40` (64 bytes total) 1147 | 1148 | 2. **SNMP Version**: 1149 | - **Tag**: `0x02` (INTEGER) 1150 | - **Length**: `0x01` (1 byte) 1151 | - **Value**: `0x00` → SNMPv1 1152 | 1153 | 3. **Community String**: 1154 | - **Tag**: `0x04` (OCTET STRING) 1155 | - **Length**: `0x06` (6 bytes) 1156 | - **Value**: `70 75 62 6c 69 63` → "public" 1157 | 1158 | 4. **GetResponse-PDU**: 1159 | - **Tag**: `0xA2` (SNMPv1 GetResponse) 1160 | - **Length**: `0x33` (51 bytes) 1161 | 1162 | 5. **Request-ID**: 1163 | - **Tag**: `0x02` (INTEGER) 1164 | - **Length**: `0x01` (1 byte) 1165 | - **Value**: `0x01` → 1 1166 | 1167 | 6. **Error-Status**: 1168 | - **Tag**: `0x02` (INTEGER) 1169 | - **Length**: `0x01` (1 byte) 1170 | - **Value**: `0x00` → noError 1171 | 1172 | 7. **Error-Index**: 1173 | - **Tag**: `0x02` (INTEGER) 1174 | - **Length**: `0x01` (1 byte) 1175 | - **Value**: `0x00` → no error index 1176 | 1177 | 8. **Variable-Bindings**: 1178 | - **Tag**: `0x30` (SEQUENCE) 1179 | - **Length**: `0x28` (40 bytes) 1180 | - **VarBind Entry**: 1181 | - **Tag**: `0x30` (SEQUENCE) 1182 | - **Length**: `0x26` (38 bytes) 1183 | - **OID**: 1184 | - **Tag**: `0x06` (OBJECT IDENTIFIER) 1185 | - **Length**: `0x0B` (11 bytes) 1186 | - **Encoded OID**: `2B 06 01 02 01 19 03 02 01 03 01` 1187 | - Decoded: `1.3.6.1.2.1.25.3.2.1.3.1` 1188 | - **Value**: 1189 | - **Tag**: `0x04` (OCTET STRING) 1190 | - **Length**: `0x17` (23 bytes) 1191 | - **Value**: `45 50 53 4F 4E 20 58 50 2D 32 30 35 20 32 30 37 20 53 65 72 69 65 73` → "EPSON XP-205 207 Series" 1192 | 1193 | #### sysName 1194 | 1195 | The OID `1.3.6.1.2.1.25.3.2.1.3.1` is part of the **Host Resources MIB** (`HOST-RESOURCES-MIB`), defined in RFC 2790 and returns the sysName. 1196 | 1197 | Here's the breakdown: 1198 | 1199 | ``` 1200 | 1.3.6.1.2.1.25.3.2.1.3.1 1201 | │ │ │ │ │ │ │ │ │ │ │ └─ sysName, index of the hrDevice entry (1st device) 1202 | │ │ │ │ │ │ │ │ │ │ └─── hrDeviceDescr 1203 | │ │ │ │ │ │ │ │ │ └───── hrDeviceEntry 1204 | │ │ │ │ │ │ │ │ └─────── hrDeviceTable 1205 | │ │ │ │ │ │ │ └───────── hrDevice 1206 | │ │ │ │ │ │ └──────────── host, hostResourcesMibModule 1207 | │ │ │ │ │ └────────────── mib-2, mib mgmt 1208 | │ │ │ │ └──────────────── Mgmt 1209 | │ │ │ └────────────────── Internet 1210 | │ │ └──────────────────── DOD 1211 | │ └────────────────────── identified-organization, org, iso-identified-organization 1212 | └──────────────────────── ISO 1213 | ``` 1214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # epson_print_conf 2 | 3 | Epson Printer Configuration tool via SNMP (TCP/IP) 4 | 5 | ## Product Overview 6 | 7 | The Epson Printer Configuration Tool provides an interface for the configuration and monitoring of Epson printers connected via Wi-Fi using the SNMP protocol. A range of features are offered for both end-users and developers. 8 | 9 | The software also includes a configurable printer dictionary, which can be easily extended. In addition, it is possible to import and convert external Epson printer configuration databases. 10 | 11 | ## Key Features 12 | 13 | - __SNMP Interface__: Connect and manage Epson printers using SNMP over TCP/IP, supporting Wi-Fi connections (not USB). 14 | 15 | Printers are queried via Simple Network Management Protocol (SNMP) with a set of Object Identifiers (OIDs) used by Epson printers. Some of them are also valid with other printer brands. SNMP is used to manage the EEPROM and read/set specific Epson configuration. 16 | 17 | - __Detailed Status Reporting__: Produce a comprehensive printer status report (with options to focus on specific details). 18 | 19 | Epson printers produce a status response in a proprietary "new binary format" named @BDC ST2, including a data structure which is partially undocumented (such messages 20 | start with `@BDC [SP] ST2 [CR] [LF]` ...). @BDC ST2 is used to convey various aspects of the status of the printer, such as errors, paper status, ink and more. The element fields of this format may vary depending on the printer model. The *Epson Printer Configuration Tool* can decode all element fields found in publicly available Epson Programming Manuals of various printer models (a relevant subset of fields used by the Epson printers). 21 | 22 | - __Advanced Maintenance Functions__: 23 | 24 | - Open the Web interface of the printer (via the default browser). 25 | 26 | - Clean Nozzles. 27 | 28 | Standard cleaning cycle on the selected nozzle group, allowing to select black/color nozzles. 29 | 30 | - Power Clean of the nozzles. 31 | 32 | Uses a higher quantity of ink to perform a deeper cleaning cycle. Power cleaning also consumes more ink and fills the waste ink tank more quickly. It should only be used when normal cleaning is insufficient. 33 | 34 | - Print Test Patterns. 35 | 36 | Execute a set of test printing functions: 37 | 38 | - Standard Nozzle Test – Ask the printer to print its internal predefined pattern. 39 | - Alternative Nozzle Test – Use an alternative predefined pattern. 40 | - Color Test Pattern – Print a b/w and color page, optimized for Epson XP-200 series printers. 41 | - Advance Paper – Move the loaded sheet forward by a specified number of lines without printing. 42 | - Feed Multiple Sheets – Pass a specified number of sheets through the printer without printing. 43 | 44 | - Temporary reset of the ink waste counter. 45 | 46 | The ink waste counters track the amount of ink discarded during maintenance tasks to prevent overflow in the waste ink pads. Once the counters indicate that one of the printer pads is full, the printer will stop working to avoid potential damage or ink spills. The "Printer status" button includes information showing the levels of the waste ink tanks; specifically, two sections are relevant: "Maintenance box information" ("maintenance_box_...") and "Waste Ink Levels" ("waste_ink_levels"). The former has a counter associated for each tank, which indicates the number of temporary resets performed by the user to temporarily restore a disabled printer. 47 | 48 | The feature to temporarily reset the ink waste counter is effective if the Maintenance box information reports that the Maintenance Box is full; it temporarily bypasses the ink waste tank full warning, which would otherwise disable printing. It is important to know that this setting is reset upon printer reboot (it does not affect the EEPROM) and can be repeated. Each time the Maintenance box status switches from "full" to "not full", the "ink replacement cleaning counter" is increased. A pad maintenance or tank replacement has to be programmed meanwhile. 49 | 50 | - Reset the ink waste counter. 51 | 52 | This feature permanently resets the ink waste counter. 53 | 54 | Resetting the ink waste counter extends the printer operation while a physical pad maintenance or tank replacement is programmed (operation that shall necessarily be pefromed). 55 | 56 | - Adjust the power-off timer (for energy efficiency). 57 | 58 | - Change the _First TI Received Time_, 59 | 60 | The *First TI Received Time* in Epson printers typically refers to the timestamp of the first transmission instruction to the printer. This feature tracks when the printer first operated. 61 | 62 | - Change the printer WiFi MAC address and the printer serial number (typically used in specialized scenarios where specific device identifiers are required). 63 | 64 | - Read and write to EEPROM addresses. 65 | 66 | - Dump and analyze sets of EEPROM addresses. 67 | 68 | - Detect the access key (*read_key* and *write_key*) and some attributes of the printer configuration. 69 | 70 | The GUI includes some features that attempt to detect the attributes of an Epson printer whose model is not included in the configuration; such features can also be used with known printers, to detect additional parameters. 71 | 72 | - Import and export printer configuration datasets in various formats: epson_print_conf pickle, Reinkpy XML, Reinkpy TOML. 73 | 74 | - Interactive Console (API Playground). 75 | 76 | The application includes an integrated Interactive Console. It allows Python developers to interact with the application's runtime environment, evaluate expressions, test APIs, and inspect variables. This console acts as a live API Playground, ideal for debugging, printer configuration testing and rapid prototyping. 77 | 78 | - Access various administrative and debugging options. 79 | 80 | - __Available Interfaces__: 81 | - __Graphical User Interface__: [Tcl/Tk](https://en.wikipedia.org/wiki/Tk_(software)) platform-independent GUI with an autodiscovery function that detects printer IP addresses and model names. 82 | - __Command Line Tool__: For users who prefer command-line interactions, providing the full set of features. 83 | - __Python API Interface__: For developers to integrate and automate printer management tasks. 84 | 85 | Note on the ink waste counter reset feature: resetting the ink waste counter is just removing a lock; not replacing the tank will reduce the print quality and make the ink spill. 86 | 87 | ## Installation 88 | 89 | Install requirements using *requirements.txt*: 90 | 91 | ```bash 92 | git clone https://github.com/Ircama/epson_print_conf 93 | cd epson_print_conf 94 | pip install -r requirements.txt 95 | ``` 96 | 97 | On Linux, you might also install the tkinter module: `sudo apt install python3-tk`. 98 | 99 | This program exploits [pysnmp v7+](https://github.com/lextudio/pysnmp) and [pysnmp-sync-adapter](https://github.com/Ircama/pysnmp-sync-adapter). 100 | 101 | To print data to Epson printers via LPR, it also uses the [epson_escp2](https://github.com/Ircama/epson_escp2) ESC/P2 encoder/decoder and the [PyPrintLpr](https://github.com/Ircama/PyPrintLpr/) LPR (RFC 1179) printer client. You can simulate the LPR interface of an Epson printer via `python3 -m pyprintlpr server -a 192.168.178.29 -e 3289,161 -d -s -l 515,9100 -I`. 102 | 103 | It is tested with Ubuntu / Windows Subsystem for Linux, Windows. 104 | 105 | ## Usage 106 | 107 | ### Running the pre-built GUI executable code 108 | 109 | The *epson_print_conf.zip* archive in the [Releases](https://github.com/Ircama/epson_print_conf/releases/latest) folder incudes the *epson_print_conf.exe* executable asset; the ZIP archive is auto-generated by a [GitHub Action](.github/workflows/build.yml). *epson_print_conf.exe* is a Windows GUI that can be directly executed. 110 | 111 | ### Running the GUI with Python 112 | 113 | Run *ui.py* as in this example: 114 | 115 | ``` 116 | python ui.py 117 | ``` 118 | 119 | This GUI runs on any Operating Systems supported by Python (not just Windows), but needs that [Tkinter](https://docs.python.org/3/library/tkinter.html) is installed. While the *Tkinter* package might be generally available by default with recent Python versions for Windows, [it needs a specific installation on other Operating Systems](https://stackoverflow.com/questions/76105218/why-does-tkinter-or-turtle-seem-to-be-missing-or-broken-shouldnt-it-be-part). 120 | 121 | GUI usage: 122 | 123 | ``` 124 | usage: ui.py [-h] [-m MODEL] [-a HOSTNAME] [-P PICKLE_FILE] [-O] [-d] 125 | 126 | optional arguments: 127 | -h, --help show this help message and exit 128 | -m MODEL, --model MODEL 129 | Printer model. Example: -m XP-205 130 | -a HOSTNAME, --address HOSTNAME 131 | Printer host name or IP address. (Example: -a 192.168.1.87) 132 | -P PICKLE_FILE, --pickle PICKLE_FILE 133 | Load a pickle configuration archive saved by parse_devices.py 134 | -O, --override Replace the default configuration with the one in the pickle file instead of merging (default is to merge) 135 | -d, --debug Print debug information 136 | 137 | epson_print_conf GUI 138 | ``` 139 | 140 | ## Quick Start on macOS via Docker 141 | 142 | Prerequirements: Docker, a VNC client (e.g. TigerVNC, RealVNC, Remmina) 143 | 144 | To install TigerVNC Viewer via Homebrew: 145 | 146 | ```bash 147 | brew install --cask tigervnc-viewer 148 | ``` 149 | 150 | Build the Docker Image: 151 | 152 | ```bash 153 | git clone https://github.com/Ircama/epson_print_conf 154 | cd epson_print_conf 155 | sudo docker build -t epson_print_conf . 156 | ``` 157 | 158 | Run the Container: 159 | 160 | ```bash 161 | sudo docker run --name epson_print_conf -p 5990:5990 epson_print_conf 162 | ``` 163 | 164 | or 165 | 166 | ``` 167 | sudo docker run --publish 5990:5990 ircama/epson_print_conf 168 | ``` 169 | 170 | Open your VNC client and connect: 171 | 172 | ```bash 173 | vncviewer localhost:90 174 | ``` 175 | 176 | (Password: 1234). 177 | 178 | ## Usage notes 179 | 180 | ### How to import an external printer configuration DB 181 | 182 | With the GUI, the following operations are possible (from the file menu): 183 | 184 | - Load a PICKLE configuration file or web URL. 185 | 186 | This operation allows to open a file saved with the GUI ("Save the selected printer configuration to a PICKLE file") or with the *parse_devices.py* utility. In addition to the printer configuration DB, this file includes the last used IP address and printer model in order to simplify the GUI usage. 187 | 188 | - Import an XML configuration file or web URL 189 | 190 | This option allows to import the XML configuration file downloaded from . Alternatively, this option directly accepts the [source Web URL](https://codeberg.org/attachments/147f41a3-a6ea-45f6-8c2a-25bac4495a1d) of this file, incorporating the download operation into the GUI. 191 | 192 | - Import a TOML configuration file or web URL 193 | 194 | Similar to the XML import, this option allows to load the TOML configuration file downloaded from and also accepts the [source Web URL](https://codeberg.org/atufi/reinkpy/raw/branch/main/reinkpy/epson.toml) of this file, incorporating the download operation into the GUI. 195 | 196 | Other menu options allow to filter or clean up the configuration list, as well as select a specific printer model and then save data to a PICKLE file. 197 | 198 | ### How to detect parameters of an unknown printer 199 | 200 | - Detect Printers: 201 | 202 | Start by pressing the *Detect Printers* button. This action generates a tree view, which helps in analyzing the device's parameters. The printer model field should automatically populate if detection is successful. 203 | 204 | - Detect Access Keys: 205 | 206 | If the printer is not listed in the configuration or is not manageable, press *Detect Access Keys.* This process may take several minutes to complete. 207 | 208 | - If the message *"Could not detect read_key."* appears at the end, it means the printer cannot be controlled with the current software version (refer to "Known Incompatible Models" below). 209 | 210 | - If no errors are reported in the output, proceed by pressing *Detect Configuration.* 211 | 212 | - Analyze Results: 213 | 214 | Each of these operations generates both a tree view and a text view. These outputs help determine if an existing configured model closely matches or is identical to the target printer. Use the right mouse button to switch between the two views for easier analysis. 215 | 216 | - Important Notes: 217 | 218 | - These processes can take several minutes to complete. Ensure the printer remains powered on throughout the entire operation. 219 | - To avoid interruptions, consider temporarily disabling the printer's auto power-off timer. 220 | 221 | ### How to revert a change performed through the GUI 222 | 223 | The GUI displays a `[NOTE]` in the status box before performing any change, specifying the current EEPROM values before the rewrite operation. This line can be copied and pasted as is into the text box that appears when the "Write EEPROM" button is pressed; the execution of the related action reverts the changes to their original values. 224 | 225 | It is recommended to copy the status history and keep it in a safe place after making changes, so that a reverse operation can be performed when needed. 226 | 227 | ### Known incompatible models 228 | 229 | Some recent firmwares supported by new printers disabled SNMP EEPROM management or changed the access mode (possibly for security reasons). 230 | 231 | For the following models there is no known way to read the EEPROM via SNMP protocol using the adopted read/write key and the related algorithm: 232 | 233 | - [XP-7100 with firmware version YL25O7 (25 Jul 2024)](https://github.com/Ircama/epson_print_conf/issues/42) (firmware YL11K6 works) 234 | - Possibly [ET-7700](https://github.com/Ircama/epson_print_conf/issues/46) 235 | - [ET-2800](https://github.com/Ircama/epson_print_conf/issues/27) 236 | - [ET-2814](https://github.com/Ircama/epson_print_conf/issues/42#issuecomment-2571587444) 237 | - [ET-2850, ET-2851, ET-2853, ET-2855, ET-2856](https://github.com/Ircama/epson_print_conf/issues/26) 238 | - [ET-4800](https://github.com/Ircama/epson_print_conf/issues/29) with new firmware (older firmware might work) 239 | - [L3250](https://github.com/Ircama/epson_print_conf/issues/35) 240 | - [L3260](https://github.com/Ircama/epson_print_conf/issues/66) with firmware version 05.23.XE21P2 241 | - [L18050](https://github.com/Ircama/epson_print_conf/issues/47) 242 | - [EcoTank ET-2862 with firmware 05.18.XF12OB dated 12/11/2024](https://github.com/Ircama/epson_print_conf/discussions/58) and possibly ET-2860 / 2861 / 2863 / 2865 series. 243 | - [XP-2200 with firmware 06.58.IU05P2](https://github.com/Ircama/epson_print_conf/issues/51) 244 | 245 | ~~The button "Temporary Reset Waste Ink Levels" should still work with these printers.~~ 246 | 247 | ### Using the command-line tool 248 | 249 | ``` 250 | python epson_print_conf.py [-h] -m MODEL -a HOSTNAME [-p PORT] [-i] [-q QUERY_NAME] 251 | [--reset_waste_ink] [--temp_reset_waste_ink] [-d] 252 | [--write-first-ti-received-time YEAR MONTH DAY] 253 | [--write-poweroff-timer MINUTES] [--dry-run] [-R ADDRESS_SET] 254 | [-W ADDRESS_VALUE_SET] [-e FIRST_ADDRESS LAST_ADDRESS] 255 | [--detect-key] [-S SEQUENCE_STRING] [-t TIMEOUT] [-r RETRIES] 256 | [-c CONFIG_FILE] [--simdata SIMDATA_FILE] [-P PICKLE_FILE] [-O] 257 | 258 | Optional arguments: 259 | -h, --help show this help message and exit 260 | -m MODEL, --model MODEL 261 | Printer model. Example: -m XP-205 (use ? to print all supported 262 | models) 263 | -a HOSTNAME, --address HOSTNAME 264 | Printer host name or IP address. (Example: -a 192.168.1.87) 265 | -p PORT, --port PORT Printer port (default is 161) 266 | -i, --info Print all available information and statistics (default option) 267 | -q QUERY_NAME, --query QUERY_NAME 268 | Print specific information. (Use ? to list all available queries) 269 | --reset_waste_ink Reset all waste ink levels to 0 270 | --temp_reset_waste_ink 271 | Temporary reset waste ink levels 272 | -d, --debug Print debug information 273 | --write-first-ti-received-time YEAR MONTH DAY 274 | Change the first TI received time 275 | --write-poweroff-timer MINUTES 276 | Update the poweroff timer. Use 0xffff or 65535 to disable it. 277 | --dry-run Dry-run change operations 278 | -R ADDRESS_SET, --read-eeprom ADDRESS_SET 279 | Read the values of a list of printer EEPROM addreses. Format is: 280 | address [, ...] 281 | -W ADDRESS_VALUE_SET, --write-eeprom ADDRESS_VALUE_SET 282 | Write related values to a list of printer EEPROM addresses. Format 283 | is: address: value [, ...] 284 | -e FIRST_ADDRESS LAST_ADDRESS, --eeprom-dump FIRST_ADDRESS LAST_ADDRESS 285 | Dump EEPROM 286 | --detect-key Detect the read_key via brute force 287 | -S SEQUENCE_STRING, --write-sequence-to-string SEQUENCE_STRING 288 | Convert write sequence of numbers to string. 289 | -t TIMEOUT, --timeout TIMEOUT 290 | SNMP GET timeout (floating point argument) 291 | -r RETRIES, --retries RETRIES 292 | SNMP GET retries (floating point argument) 293 | -c CONFIG_FILE, --config CONFIG_FILE 294 | read a configuration file including the full log dump of a previous 295 | operation with '-d' flag (instead of accessing the printer via SNMP) 296 | --simdata SIMDATA_FILE 297 | write SNMP dictionary map to simdata file 298 | -P PICKLE_FILE, --pickle PICKLE_FILE 299 | Load a pickle configuration archive saved by parse_devices.py 300 | -O, --override Replace the default configuration with the one in the pickle file 301 | instead of merging (default is to merge) 302 | 303 | Epson Printer Configuration via SNMP (TCP/IP) 304 | ``` 305 | 306 | Examples: 307 | 308 | ```bash 309 | # Print the status information (-i is not needed): 310 | python3 epson_print_conf.py -m XP-205 -a 192.168.1.87 -i 311 | 312 | # Reset all waste ink levels to 0: 313 | python3 epson_print_conf.py -m XP-205 -a 192.168.1.87 --reset_waste_ink 314 | 315 | # Change the first TI received time to 31 December 2016: 316 | python3 epson_print_conf.py -m XP-205 -a 192.168.1.87 --write-first-ti-received-time 2016 12 31 317 | 318 | # Change the power off timer to 15 minutes: 319 | python3 epson_print_conf.py -a 192.168.1.87 -m XP-205 --write-poweroff-timer 15 320 | 321 | # Detect the read_key via brute force: 322 | python3 epson_print_conf.py -m XP-205 -a 192.168.1.87 --detect-key 323 | 324 | # Only print status information: 325 | python3 epson_print_conf.py -m XP-205 -a 192.168.1.87 -q printer_status 326 | 327 | # Only print SNMP 'MAC Address' name: 328 | python3 epson_print_conf.py -m XP-205 -a 192.168.1.87 -q 'MAC Address' 329 | 330 | # Only print SNMP 'Lang 5' name: 331 | python3 epson_print_conf.py -m XP-205 -a 192.168.1.87 -q 'Lang 5' 332 | 333 | # Write value 1 to the EEPROM address 173 and value 0xDE to the EEPROM address 172: 334 | python3 epson_print_conf.py -m XP-205 -a 192.168.1.87 -W 173:1,172:0xde 335 | 336 | # Read EEPROM address 173 and EEPROM address 172: 337 | python3 epson_print_conf.py -m XP-205 -a 192.168.1.87 -R 173,172 338 | ``` 339 | 340 | ## Creating an executable asset for the GUI 341 | 342 | Alternatively to running the GUI via `python ui.py`, it is possible to build an executable file via *pyinstaller*. 343 | 344 | Install *pyinstaller* with `pip install pyinstaller`. 345 | 346 | The *epson_print_conf.spec* file helps building the executable program. Run it with the following command. 347 | 348 | ```bash 349 | pip install pyinstaller # if not yet installed 350 | pyinstaller epson_print_conf.spec -- --default 351 | ``` 352 | 353 | Then run the executable file created in the *dist/* folder, which has the same options of `ui.py`. 354 | 355 | It is also possible to automatically load a previously created configuration file that has to be named *epson_print_conf.pickle*, merging it with the program configuration. (See below the *parse_devices.py* utility.) To build the executable program with this file, run the following command: 356 | 357 | ```bash 358 | pip install pyinstaller # if not yet installed 359 | curl -o devices.xml https://codeberg.org/attachments/147f41a3-a6ea-45f6-8c2a-25bac4495a1d 360 | python3 parse_devices.py -a 192.168.178.29 -s XP-205 -p epson_print_conf.pickle # use your default IP address and printer model as default settings for the GUI 361 | pyinstaller epson_print_conf.spec 362 | ``` 363 | 364 | Same procedure using the Reinkpy's *epson.toml* file (in place of *devices.xml*): 365 | 366 | ```bash 367 | pip install pyinstaller # if not yet installed 368 | curl -o epson.toml https://codeberg.org/atufi/reinkpy/raw/branch/main/reinkpy/epson.toml 369 | python3 parse_devices.py -Ta 192.168.178.29 -s XP-205 -p epson_print_conf.pickle # use your default IP address and printer model as default settings for the GUI 370 | pyinstaller epson_print_conf.spec 371 | ``` 372 | 373 | When embedding *epson_print_conf.pickle*, the created program does not have options and starts with the default IP address and printer model defined in the build phase. 374 | 375 | As mentioned in the [documentation](https://pyinstaller.org/en/stable/), PyInstaller supports Windows, MacOS X, Linux and other UNIX Operating Systems. It creates an executable file which is only compatible with the operating system that is used to build the asset. 376 | 377 | This repository includes a Windows *epson_print_conf.exe* executable file which is automatically generated by a [GitHub Action](.github/workflows/build.yml). It is packaged in a ZIP file named *epson_print_conf.zip* and uploaded into the [Releases](https://github.com/Ircama/epson_print_conf/releases/latest) folder. 378 | 379 | ## Utilities and notes 380 | 381 | ### parse_devices.py 382 | 383 | Within a [report](https://codeberg.org/atufi/reinkpy/issues/12#issue-716809) in repo there is an interesting [attachment](https://codeberg.org/attachments/147f41a3-a6ea-45f6-8c2a-25bac4495a1d) which includes an extensive XML database of Epson model features. 384 | 385 | The program *parse_devices.py* transforms this XML DB into the dictionary that *epson_print_conf.py* can use. It is also able to accept the [TOML](https://toml.io/) input format used by [reinkpy](https://codeberg.org/atufi/reinkpy) in [epson.toml](https://codeberg.org/atufi/reinkpy/src/branch/main/reinkpy/epson.toml), if the `-T` option is used. 386 | 387 | Here is a simple procedure to download the *devices.xml* DB and run *parse_devices.py* to search for the XP-205 model and produce the related PRINTER_CONFIG dictionary to the standard output: 388 | 389 | ```bash 390 | curl -o devices.xml https://codeberg.org/attachments/147f41a3-a6ea-45f6-8c2a-25bac4495a1d 391 | python3 parse_devices.py -i -m XP-205 392 | ``` 393 | 394 | Same procedure, processing the *epson.toml* file: 395 | 396 | ```bash 397 | curl -o epson.toml https://codeberg.org/atufi/reinkpy/raw/branch/main/reinkpy/epson.toml 398 | python3 parse_devices.py -T -i -m XP-205 399 | ``` 400 | 401 | After generating the related printer configuration, *epson_print_conf.py* shall be manually edited to copy/paste the output of *parse_devices.py* within its PRINTER_CONFIG dictionary. Alternatively, the program is able to create a *pickle* configuration file (check the `-p` lowercase option), which the other programs can load (with the `-P` uppercase option and in addition with the optional `-O` flag). 402 | 403 | The `-m` option is optional and is used to filter the printer model in scope. If the produced output is not referred to the target model, use part of the model name as a filter (e.g., only the digits, like `parse_devices.py -i -m 315`) and select the appropriate model from the output. 404 | 405 | Program usage: 406 | 407 | ``` 408 | usage: parse_devices.py [-h] [-m PRINTER_MODEL] [-T] [-l LINE_LENGTH] [-i] [-d] [-t] [-v] [-f] [-e] 409 | [-c CONFIG_FILE] [-s DEFAULT_MODEL] [-a HOSTNAME] [-p PICKLE_FILE] [-I] [-N] 410 | [-A] [-G] [-S] [-M] 411 | 412 | optional arguments: 413 | -h, --help show this help message and exit 414 | -m PRINTER_MODEL, --model PRINTER_MODEL 415 | Filter printer model. Example: -m XP-205 416 | -T, --toml Use the Reinkpy TOML input format instead of XML 417 | -l LINE_LENGTH, --line LINE_LENGTH 418 | Set line length of the output (default: 120) 419 | -i, --indent Indent output of 4 spaces 420 | -d, --debug Print debug information 421 | -t, --traverse Traverse the XML, dumping content related to the printer model 422 | -v, --verbose Print verbose information 423 | -f, --full Generate additional tags 424 | -e, --errors Add last_printer_fatal_errors 425 | -c CONFIG_FILE, --config CONFIG_FILE 426 | use the XML or the Reinkpy TOML configuration file to generate the configuration; 427 | default is 'devices.xml', or 'epson.toml' if -T is used 428 | -s DEFAULT_MODEL, --default_model DEFAULT_MODEL 429 | Default printer model. Example: -s XP-205 430 | -a HOSTNAME, --address HOSTNAME 431 | Default printer host name or IP address. (Example: -a 192.168.1.87) 432 | -p PICKLE_FILE, --pickle PICKLE_FILE 433 | Save a pickle archive for subsequent load by ui.py and epson_print_conf.py 434 | -I, --keep_invalid Do not remove printers without write_key or without read_key 435 | -N, --keep_names Do not replace original names with converted names and add printers for all 436 | optional names 437 | -A, --no_alias Do not add aliases for same printer with different names and remove aliased 438 | printers 439 | -G, --no_aggregate_alias 440 | Do not aggregate aliases of printers with same configuration 441 | -S, --no_same_as Do not add "same-as" for similar printers with different names 442 | -M, --no_maint_level Do not add "Maintenance required levelas" in "stats" 443 | 444 | Generate printer configuration from devices.xml or from Reinkpy TOML 445 | ``` 446 | 447 | The program does not provide *printer_head_id* and *Power off timer*. 448 | 449 | #### Example to integrate new printers 450 | 451 | Suppose ET-4800 ia a printer already defined in the mentioned [attachment](https://codeberg.org/attachments/147f41a3-a6ea-45f6-8c2a-25bac4495a1d) with valid data, that you want to integrate. 452 | 453 | ```bash 454 | curl -o devices.xml https://codeberg.org/attachments/147f41a3-a6ea-45f6-8c2a-25bac4495a1d 455 | python3 parse_devices.py -m ET-4800 -p epson_print_conf.pickle 456 | python3 ui.py -P epson_print_conf.pickle 457 | ``` 458 | 459 | or (operating *epson.toml*): 460 | 461 | ```bash 462 | curl -o epson.toml https://codeberg.org/atufi/reinkpy/raw/branch/main/reinkpy/epson.toml 463 | python3 parse_devices.py -T -m ET-4800 -p epson_print_conf.pickle 464 | python3 ui.py -P epson_print_conf.pickle 465 | ``` 466 | 467 | If you also want to create an executable program: 468 | 469 | ```bash 470 | pyinstaller epson_print_conf.spec 471 | ``` 472 | 473 | ### find_printers.py 474 | 475 | *find_printers.py* can be executed via `python find_printers.py` and prints the list of the discovered printers to the standard output. It is internally used as a library by *ui.py*. 476 | 477 | Output example: 478 | 479 | ``` 480 | [{'ip': '192.168.178.29', 'hostname': 'EPSONDEFD03.fritz.box', 'name': 'EPSON XP-205 207 Series'}] 481 | ``` 482 | 483 | ### Other utilities 484 | 485 | ```python 486 | from epson_print_conf import EpsonPrinter 487 | import pprint 488 | printer = EpsonPrinter() 489 | 490 | # Decode write_key: 491 | printer.reverse_caesar(bytes.fromhex("48 62 7B 62 6F 6A 62 2B")) # last 8 bytes 492 | 'Gazania*' 493 | 494 | printer.reverse_caesar(b'Hpttzqjv') 495 | 'Gossypiu' 496 | 497 | "".join(chr(b + 1) for b in b'Gossypiu') 498 | 'Hpttzqjv' 499 | 500 | # Decode status: 501 | pprint.pprint(printer.status_parser(bytes.fromhex("40 42 44 43 20 53 54 32 0D 0A ...."))) 502 | 503 | # Decode the level of ink waste 504 | byte_sequence = "A4 2A" 505 | divider = 62.06 # divider = ink_level / waste_percent 506 | ink_level = int("".join(reversed(byte_sequence.split())), 16) 507 | waste_percent = round(ink_level / divider, 2) 508 | 509 | # Print the read key sequence in byte and hex formats: 510 | printer = EpsonPrinter(model="ET-2700") 511 | '.'.join(str(x) for x in printer.parm['read_key']) 512 | " ".join('{0:02x}'.format(x) for x in printer.parm['read_key']) 513 | 514 | # Print the write key sequence in byte and hex formats: 515 | printer = EpsonPrinter(model="ET-2700") 516 | printer.caesar(printer.parm['write_key']) 517 | printer.caesar(printer.parm['write_key'], hex=True).upper() 518 | 519 | # Print hex sequence of reading the value of EEPROM address 30 00: 520 | " ".join('{0:02x}'.format(int(x)) for x in printer.eeprom_oid_read_address(oid=0x30).split(".")[15:]).upper() 521 | 522 | # Print hex sequence of storing value 00 to EEPROM address 30 00: 523 | " ".join('{0:02x}'.format(int(x)) for x in printer.eeprom_oid_write_address(oid=0x30, value=0x0).split(".")[15:]).upper() 524 | 525 | # Print EEPROM write hex sequence of the raw ink waste reset: 526 | for key, value in printer.parm["raw_waste_reset"].items(): 527 | " ".join('{0:02x}'.format(int(x)) for x in printer.eeprom_oid_write_address(oid=key, value=value).split(".")[15:]).upper() 528 | ``` 529 | 530 | Generic query of the status of the printer (regardless of the model): 531 | 532 | ```python 533 | from epson_print_conf import EpsonPrinter 534 | import pprint 535 | printer = EpsonPrinter(hostname="192.168.1.87") 536 | pprint.pprint(printer.status_parser(printer.fetch_snmp_values("1.3.6.1.4.1.1248.1.2.2.1.1.1.4.1")[1])) 537 | ``` 538 | 539 | ## EPSON-CTRL commands over SNMP 540 | 541 | [Communication between PC and Printer can be done by several transport protocols](https://github.com/lion-simba/reink/blob/master/reink.c#L79C5-L85): ESCP/2, EJL, D4. And in addition SNMP, END4. “D4” (or “Dot 4”) is an abbreviated form of the IEEE-1284.4 specification: it provides a bi-directional, packetized link with multiple logical “sockets”. The two primary Epson-defined channels are: 542 | 543 | - EPSON-CTRL 544 | – Carries printer-control commands, status queries, configuration 545 | - Structure: 2 lowercase letters + length + payload 546 | – Also tunneled via END4 547 | - undocumented commands. 548 | 549 | - EPSON-DATA 550 | – Carries the actual print-job content: raster image streams, font/download data, macros, etc. 551 | - Allow "Remote Mode" commands, entered and terminated via a special sequence (`ESC (R BC=8 00 R E M O T E 1`, `ESC 00 00 00`); [remote mode commands](https://gimp-print.sourceforge.io/reference-html/x952.html) are partially documented and have a similar structure as EPSON-CTRL (2 letters + length + payload), but the letters are uppercase and cannot be mapped to SNMP. 552 | 553 | EPSON-CTRL can be transported over D4, or encapsulated in SNMP OIDs. Some EPSON-CTRL instructions implement a subset of Epson’s Remote Mode protocol, while others are proprietary. Such commands are named "Packet Commands" in the Epson printer Service Manuals and specifically the "EPSON LX-300+II and LX-1170II Service manuals" (old impact dot matrix printers) document "di", "st" and also "||" in the "Packet commands" table. 554 | 555 | END4 is a proprietary protocol to transport EPSON-CTRL commands [over the standard print channel](https://codeberg.org/atufi/reinkpy/issues/12#issuecomment-1660026), without using the EPSON-CTRL channel. 556 | 557 | OID Header: 558 | 559 | ``` 560 | 1.3.6.1.4.1. [SNMP_OID_ENTERPRISE] 561 | 1248. [SNMP_EPSON] 562 | 563 | 1.2.2.44.1.1.2. [OID_PRV_CTRL] 564 | 1. 565 | ``` 566 | 567 | Full OID header sequence: `1.3.6.1.4.1.1248.1.2.2.44.1.1.2.1.` 568 | 569 | Subsequent digits: 570 | 571 | - Two ASCII characters that identify the command (e.g., "st", "ex"). These are command identifiers of the EPSON-CTRL messages (Remote Mode) 572 | - 2-byte little-endian length field (gives the number of bytes in the parameter section that follows) 573 | - payload (a block of bytes that are specific to the command). 574 | 575 | The following is the list of EPSON-CTRL commands supported by the XP-205. 576 | 577 | Two-bytes|Description | Notes | Parameters 578 | :--:| ---------------------------------------------- | ----------------| ------------- 579 | \|\| | EEPROM access | Implemented in this program, the SNMP OID version is not supported by new printer firmwares | (A, B); see examples below 580 | cd | | | (0) 581 | cs | | | (0 or 1) 582 | cx | | | 583 | di | Get Device ID (identification) ("di" 01H 00H 01H), same as @EJL[SP]ID[CR][LF] | Implemented in this program | (1) 584 | ei | | | (0) 585 | ex | Set Vertical Print Page Line Mode, Roll Paper Mode | - ex BC=6 00 00 00 00 0x14 xx (Set Vertical Print Page Line Mode. xx=00 is off, xx=01 is on. If turned on, this prints vertical trim lines at the left and right margins).
- ex BC=6 00 00 00 00 0x05 xx (Set Roll Paper Mode. If xx is 0, roll paper mode is off; if xx is 1, roll paper mode is on).
- ex BC=3 00 xx yy (Appears to be a synonym for the SN command described above.) | 586 | fl | Firmware load. Enter recovery mode | | 587 | ht | Horizontal tab | | 588 | ia | List of cartridge types | Implemented in this program | (0) 589 | ii | List cartridge properties | Implemented in this program ("ii\2\0\1\1") | (1 + cartridge number) 590 | ot | Power Off Timer | Implemented in this program | (1, 1) 591 | pe | (paper ?) | | (1) 592 | pj | Pause jobs (?) | | 593 | pm | Select control language ("pm" 02H 00H 00H m1m1=0(ESC/P), 2(IBM 238x Plus emulation) | | (1) 594 | rj | Resume jobs (?) | | 595 | rp | (serial number ? ) | | (0) 596 | rs | Initialize | | (1) 597 | rw | Reset Waste | Implemented in this program | (1, 0) + [Serial SHA1 hash](https://codeberg.org/atufi/reinkpy/issues/12#issuecomment-1661250) (20 bytes) 598 | st | Get printer status ("st" 01H 00H 01H) | Implemented in this program; se below "ST2 Status Reply Codes" | (1) 599 | ti | Set printer time | ("ti" 08H 00H 00H YYYY MM DD hh mm ss) | 600 | vi | Version Information | Implemented in this program | (0) 601 | xi | | | (1) 602 | 603 | escutil.c also mentions [`ri\2\0\0\0`](https://github.com/echiu64/gutenprint/blob/master/src/escputil/escputil.c#L1944) (Attempt to reset ink) in some printer firmwares. 604 | 605 | [Other font](https://codeberg.org/KalleMP/reinkpy/src/branch/main/reinkpy/epson/core.py#L22) also mentions `pc:\x01:NA` in some printer firmwares. 606 | 607 | Reply of any non supported commands: “XX:;” FF. (XX is the command string being invalid.) 608 | 609 | ### Examples for EEPROM access 610 | 611 | #### Read EEPROM 612 | 613 | ``` 614 | 124.124.7.0. [7C 7C 07 00] 615 | 616 | 65.190.160. [41 BE A0] 617 | . 618 | ``` 619 | 620 | - 124.124: "||" = Read EEPROM (EPSON-CTRL) 621 | - 7.0: Two-byte payload length = 7 bytes (7 bytes payload length means two-byte EEPROM address, used in recent printers; old printers supported 6 bytes payload length for a single byte EEPROM address). 622 | - two bytes for the read key (named "R code" in the "EPSON LX-300+II and LX-1170II Service manuals") 623 | - 65: 'A' = read (41H) 624 | - 190: [Take the bitwise NOT of the ASCII value of 'A' = read, then mask to the lowest 8 bits](https://github.com/lion-simba/reink/blob/master/reink.c#L1414). The result is 190 (BEH). 625 | - 160: [Shift the ASCII value of 'A' (read) right by 1 and mask to 7 bits, then OR it with the highest bit of the value shifted left by 7](https://github.com/lion-simba/reink/blob/master/reink.c#L1415). The result is 160 (A0H). 626 | - two bytes for the EEPROM address (one byte if the payload length is 6 bytes) 627 | 628 | From the Epson Service manual of LX-300+II and LX-1170II (single byte form): 629 | 630 | ``` 631 | “||” 06H 00H r1 r2 41H BEH A0H d1 632 | r1, r2 means R code. (e.g. r1=A8, r2=5Ah) 633 | d1 : EEPROM address (00h - FFh) 634 | ``` 635 | 636 | SNMP OID example: `1.3.6.1.4.1.1248.1.2.2.44.1.1.2.1.124.124.7.0.73.8.65.190.160.48.0` 637 | 638 | EEPROM data reply: “@BDC” SP “PS” CR LF “EE:” “;” FF. 639 | 640 | #### Write EEPROM 641 | 642 | - 124.124: "||" = Read EEPROM (EPSON-CTRL) 643 | - 16.0: Two-byte payload length = 16 bytes 644 | - two bytes for the read key 645 | - 66: 'B' = write 646 | - 189: Take the bitwise NOT of the ASCII value of 'B' = write, then mask to the lowest 8 bits. The result is 189. 647 | - 33: Shift the ASCII value of 'B' (write) right by 1 and mask to 7 bits, then OR it with the highest bit of the value shifted left by 7. The result is 33. 648 | - two bytes for the EEPROM address 649 | - one byte for the value 650 | - 8 bytes for the write key 651 | 652 | ``` 653 | 7C 7C 10 00 [124.124.16.0.] 654 | 655 | 42 BD 21 [66.189.33.] 656 | . 657 | 658 | 659 | ``` 660 | 661 | SNMP OID example: `7C 7C 10 00 49 08 42 BD 21 30 00 1A 42 73 62 6F 75 6A 67 70` 662 | 663 | #### Returned data 664 | 665 | Example of Read EEPROM (@BDC PS): 666 | 667 | ``` 668 | <01> @BDC PS <0d0a> EE:0032AC; 669 | EE: = EEPROM Read 670 | 0032 = Memory address 671 | AC = Value 672 | ``` 673 | 674 | ### Related API 675 | 676 | #### epctrl_snmp_oid() 677 | 678 | `self.epctrl_snmp_oid(two-char-command, payload)` converts an EPSON-CTRL Remote command into a SNMP OID format suitable for use in SNMP operations. 679 | 680 | **Parameters** 681 | 682 | * `command` (`str`): 683 | A two-character string representing the EPSON Remote Mode command. 684 | 685 | * `payload` (`int | list[int] | bytes`): 686 | The payload to send with the command. It can be: 687 | 688 | * An integer, representing a single byte-argument. 689 | * A list of integers (converted to bytes) 690 | * A `bytes` object (used as-is) 691 | 692 | It returns a SNMP OID string to be used by `self.printer.fetch_oid_values()`. 693 | 694 | `self.epctrl_snmp_oid("ei", 0)` is equivalent to `self.epctrl_snmp_oid("ei", [0])` or `self.epctrl_snmp_oid("ei", b'\x00')`. 695 | 696 | `self.epctrl_snmp_oid("st", [1, 0, 1])` is equivalent to `self.epctrl_snmp_oid("ei", b'\x01\x00\x01')`. 697 | 698 | #### fetch_oid_values() 699 | 700 | `self.fetch_oid_values(oid)` fetches the oid value. When oid is a string, it returns a list of a single element consisting of a tuple: data type (generally 'OctetString') and data value in bytes. 701 | 702 | To return the value of the OID query: `self.fetch_oid_values(oid)[0][1]`. 703 | 704 | ### Testing EPSON-CTRL commands 705 | 706 | Open the *epson_print_conf* application, set printer model and IP address, test printer connection. Then: Settings > Debug Shell. 707 | 708 | The following are examples of instructions to test the EPSON-CTRL commands: 709 | 710 | ```python 711 | # cs 712 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("cs", 0))[0][1] 713 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("cs", 1))[0][1] 714 | 715 | # cd 716 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("cd", 0))[0][1] 717 | 718 | # ex 719 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("ex", [0, 0, 0, 0, 25, 0]))[0][1] 720 | 721 | from datetime import datetime 722 | now = datetime.now() 723 | data = bytearray() 724 | data = b'\x00' 725 | data += now.year.to_bytes(2, 'big') # Year 726 | data += bytes([now.month, now.day, now.hour, now.minute, now.second]) 727 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("ti", data))[0][1] 728 | 729 | # Firmware load. Enter recovery mode 730 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("fl", 1))[0][1] 731 | 732 | # ei 733 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("ei", 0))[0][1] 734 | 735 | # pe 736 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("pe", 1))[0][1] 737 | 738 | # rp (serial number ? ) 739 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("rp", 0))[0][1] 740 | 741 | # xi (?) 742 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("xi", 1))[0][1] 743 | 744 | # Print Meter 745 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("pm", 1))[0][1] 746 | 747 | # rs 748 | self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid("rs", 1))[0][1] 749 | 750 | # Detect all commands: 751 | ec_sequences = [ 752 | decoded 753 | for i in range(0x10000) 754 | if (b := i.to_bytes(2, 'big'))[0] and b[1] 755 | and (decoded := b.decode('utf-8', errors='ignore')).encode('utf-8') == b 756 | ] 757 | for i in ec_sequences: 758 | if len(i) != 2: 759 | continue 760 | r = self.printer.fetch_oid_values(self.printer.epctrl_snmp_oid(i, 0)) 761 | if r[0][1] != b'\x00' + i.encode() + b':;\x0c': 762 | print(r) 763 | ``` 764 | 765 | ## Remote Mode commands 766 | 767 | The [PyPrintLpr](https://github.com/Ircama/PyPrintLpr) module is used for sending Epson LPR commands over a LPR connection. This channel does not support receiving payload responses from the printer. 768 | 769 | Refer to [Epson Remote Mode commands](https://github.com/Ircama/PyPrintLpr?tab=readme-ov-file#epson-remote-mode-commands) and to https://gimp-print.sourceforge.io/reference-html/x952.html for a description of the known Remote Mode commands. 770 | 771 | Check `self.printer.check_nozzles()` and `self.printer.clean_nozzles(0)` for examples of usage. 772 | 773 | The following code prints the nozzle-check print pattern (copy and paste the code to the Interactive Console after selecting a printer and related host address): 774 | 775 | ```python 776 | from pyprintlpr import LprClient 777 | from hexdump2 import hexdump 778 | 779 | with LprClient('192.168.1.100', port="LPR", queue='PASSTHRU') as lpr: 780 | data = ( 781 | lpr.EXIT_PACKET_MODE + # Exit packet mode 782 | lpr.ENTER_REMOTE_MODE + # Engage remote mode commands 783 | lpr.PRINT_NOZZLE_CHECK + # Issue nozzle-check print pattern 784 | lpr.EXIT_REMOTE_MODE + # Disengage remote control 785 | lpr.JOB_END # Mark maintenance job complete 786 | ) 787 | print("\nDump of data:\n") 788 | hexdump(data) 789 | lpr.send(data) 790 | ``` 791 | 792 | ## ST2 Status Reply Codes 793 | 794 | ST2 Status Reply Codes that are decoded by *epson_print_conf*; they are mentioned in various Epson programming guides: 795 | 796 | Staus code | Description 797 | :---------:|------------- 798 | 01 | Status code 799 | 02 | Error code 800 | 03 | Self print code 801 | 04 | Warning code 802 | 06 | Paper path 803 | 07 | Paper mismatch error 804 | 0c | Cleaning time information 805 | 0d | Maintenance tanks 806 | 0e | Replace cartridge information 807 | 0f | Ink information 808 | 10 | Loading path information 809 | 13 | Cancel code 810 | 14 | Cutter information 811 | 18 | Stacker(tray) open status 812 | 19 | Current job name information 813 | 1c | Temperature information 814 | 1f | Serial 815 | 35 | Paper jam error information 816 | 36 | Paper count information 817 | 37 | Maintenance box information 818 | 3d | Printer I/F status 819 | 40 | Serial No. information 820 | 45 | Ink replacement counter (TBV) 821 | 46 | Maintenance_box_replacement_counter (TBV) 822 | 823 | Many printers return additional codes whose meanings are unknown and not documented. 824 | 825 | ## API Interface 826 | 827 | ### Specification 828 | 829 | ```python 830 | EpsonPrinter(conf_dict, replace_conf, model, hostname, port, timeout, retries, dry_run) 831 | ``` 832 | 833 | - `conf_dict`: optional configuration file in place of the default PRINTER_CONFIG (optional, default to `{}`) 834 | - `replace_conf`: (optional, default to False) set to True to replace PRINTER_CONFIG with `conf_dict` instead of merging it 835 | - `model`: printer model 836 | - `hostname`: IP address or network name of the printer 837 | - `port`: SNMP port number (default is 161) 838 | - `timeout`: printer connection timeout in seconds (float) 839 | - `retries`: connection retries if error or timeout occurred 840 | - `dry_run`: boolean (True if write dry-run mode is enabled) 841 | 842 | ### Exceptions 843 | 844 | ``` 845 | TimeoutError 846 | ValueError 847 | ``` 848 | 849 | (And *pysnmp* exceptions.) 850 | 851 | ### Sample 852 | 853 | ```python 854 | from epson_print_conf import EpsonPrinter 855 | import logging 856 | 857 | logging.basicConfig(level=logging.DEBUG, format="%(message)s") # if logging is needed 858 | 859 | printer = EpsonPrinter(model="XP-205", hostname="192.168.178.29") 860 | 861 | if not printer.parm: 862 | print("Unknown printer") 863 | quit() 864 | 865 | stats = printer.stats() 866 | print("stats:", stats) 867 | 868 | ret = printer.get_snmp_info() 869 | print("get_snmp_info:", ret) 870 | ret = printer.get_serial_number() 871 | print("get_serial_number:", ret) 872 | ret = printer.get_firmware_version() 873 | print("get_firmware_version:", ret) 874 | ret = printer.get_printer_head_id() 875 | print("get_printer_head_id:", ret) 876 | ret = printer.get_cartridges() 877 | print("get_cartridges:", ret) 878 | ret = printer.get_printer_status() 879 | print("get_printer_status:", ret) 880 | ret = printer.get_ink_replacement_counters() 881 | print("get_ink_replacement_counters:", ret) 882 | ret = printer.get_waste_ink_levels() 883 | print("get_waste_ink_levels:", ret) 884 | ret = printer.get_last_printer_fatal_errors() 885 | print("get_last_printer_fatal_errors:", ret) 886 | ret = printer.get_stats() 887 | print("get_stats:", ret) 888 | 889 | printer.reset_waste_ink_levels() 890 | printer.brute_force_read_key() 891 | printer.write_first_ti_received_time(2000, 1, 2) 892 | 893 | # Dump all printer configuration parameters 894 | from pprint import pprint 895 | pprint(printer.parm) 896 | ``` 897 | 898 | [black](https://pypi.org/project/black/) way to dump all printer parameters: 899 | 900 | ```python 901 | import textwrap, black 902 | from epson_print_conf import EpsonPrinter 903 | printer = EpsonPrinter(model="TX730WD", hostname="192.168.178.29") 904 | mode = black.Mode(line_length=200, magic_trailing_comma=False) 905 | print(textwrap.indent(black.format_str(f'"{printer.model}": ' + repr(printer.parm), mode=mode), 8*' ')) 906 | 907 | # Print status: 908 | print(black.format_str(f'"{printer.model}": ' + repr(printer.stats()), mode=mode)) 909 | ``` 910 | 911 | ## Output example 912 | Example of advanced printer status with an XP-205 printer: 913 | 914 | ```python 915 | {'cartridge_information': [{'data': '0D081F172A0D04004C', 916 | 'ink_color': [1811, 'Black'], 917 | 'ink_quantity': 76, 918 | 'production_month': 8, 919 | 'production_year': 2013}, 920 | {'data': '15031D06230D080093', 921 | 'ink_color': [1814, 'Yellow'], 922 | 'ink_quantity': 69, 923 | 'production_month': 3, 924 | 'production_year': 2021}, 925 | {'data': '150317111905020047', 926 | 'ink_color': [1813, 'Magenta'], 927 | 'ink_quantity': 49, 928 | 'production_month': 3, 929 | 'production_year': 2021}, 930 | {'data': '14091716080501001D', 931 | 'ink_color': [1812, 'Cyan'], 932 | 'ink_quantity': 29, 933 | 'production_month': 9, 934 | 'production_year': 2020}], 935 | 'cartridges': ['18XL', '18XL', '18XL', '18XL'], 936 | 'firmware_version': 'RF11I5 11 May 2018', 937 | 'ink_replacement_counters': {('Black', '1B', 1), 938 | ('Black', '1L', 19), 939 | ('Black', '1S', 2), 940 | ('Cyan', '1B', 1), 941 | ('Cyan', '1L', 8), 942 | ('Cyan', '1S', 1), 943 | ('Magenta', '1B', 1), 944 | ('Magenta', '1L', 6), 945 | ('Magenta', '1S', 1), 946 | ('Yellow', '1B', 1), 947 | ('Yellow', '1L', 10), 948 | ('Yellow', '1S', 1)}, 949 | 'last_printer_fatal_errors': ['08', 'F1', 'F1', 'F1', 'F1', '10'], 950 | 'printer_head_id': '...', 951 | 'printer_status': {'cancel_code': 'No request', 952 | 'ink_level': [(1, 0, 'Black', 'Black', 76), 953 | (5, 3, 'Yellow', 'Yellow', 69), 954 | (4, 2, 'Magenta', 'Magenta', 49), 955 | (3, 1, 'Cyan', 'Cyan', 29)], 956 | 'jobname': 'Not defined', 957 | 'loading_path': 'fixed', 958 | 'maintenance_box_1': 'not full (0)', 959 | 'maintenance_box_2': 'not full (0)', 960 | 'maintenance_box_reset_count_1': 0, 961 | 'maintenance_box_reset_count_2': 0, 962 | 'paper_path': 'Cut sheet (Rear)', 963 | 'ready': True, 964 | 'status': (4, 'Idle'), 965 | 'unknown': [('0x24', b'\x0f\x0f')]}, 966 | 'serial_number': '...', 967 | 'snmp_info': {'Descr': 'EPSON Built-in 11b/g/n Print Server', 968 | 'EEPS2 firmware version': 'EEPS2 Hard Ver.1.00 Firm Ver.0.50', 969 | 'Emulation 1': 'unknown', 970 | 'Emulation 2': 'ESC/P2', 971 | 'Emulation 3': 'BDC', 972 | 'Emulation 4': 'other', 973 | 'Emulation 5': 'other', 974 | 'Epson Model': 'XP-205 207 Series', 975 | 'IP Address': '192.168.1.87', 976 | 'IPP_URL': 'http://192.168.1.87:631/Epson_IPP_Printer', 977 | 'IPP_URL_path': 'Epson_IPP_Printer', 978 | 'Lang 1': 'unknown', 979 | 'Lang 2': 'ESCPL2', 980 | 'Lang 3': 'BDC', 981 | 'Lang 4': 'D4', 982 | 'Lang 5': 'ESCPR1', 983 | 'MAC Addr': '...', 984 | 'MAC Address': '...', 985 | 'Model': 'EPSON XP-205 207 Series', 986 | 'Model short': 'XP-205 207 Series', 987 | 'Name': '...', 988 | 'Power Off Timer': '0.5 hours', 989 | 'Print input': 'Auto sheet feeder', 990 | 'Total printed pages': '0', 991 | 'UpTime': '00:02:08', 992 | 'WiFi': '...', 993 | 'device_id': 'MFG:EPSON;CMD:ESCPL2,BDC,D4,D4PX,ESCPR1;MDL:XP-205 ' 994 | '207 Series;CLS:PRINTER;DES:EPSON XP-205 207 ' 995 | 'Series;CID:EpsonRGB;FID:FXN,DPN,WFA,ETN,AFN,DAN;RID:40;'}, 996 | 'stats': {'First TI received time': '...', 997 | 'Ink replacement cleaning counter': 78, 998 | 'Maintenance required level of 1st waste ink counter': 94, 999 | 'Maintenance required level of 2nd waste ink counter': 94, 1000 | 'Manual cleaning counter': 129, 1001 | 'Timer cleaning counter': 4, 1002 | 'Total print page counter': 11569, 1003 | 'Total print pass counter': 514602, 1004 | 'Total scan counter': 4973, 1005 | 'Power off timer': 30}, 1006 | 'waste_ink_levels': {'borderless_waste': 4.72, 'main_waste': 90.8}} 1007 | ``` 1008 | 1009 | ## Resources 1010 | 1011 | ### snmpget 1012 | 1013 | Installation with Linux: 1014 | 1015 | ``` 1016 | sudo apt-get install snmp 1017 | ``` 1018 | 1019 | There are also [binaries for Windows](https://netcologne.dl.sourceforge.net/project/net-snmp/net-snmp%20binaries/5.7-binaries/net-snmp-5.7.0-1.x86.exe?viasf=1) which include snmpget.exe, running with the same arguments. 1020 | 1021 | Usage: 1022 | 1023 | ``` 1024 | # Read address 173.0 1025 | snmpget -v1 -d -c public 192.168.1.87 1.3.6.1.4.1.1248.1.2.2.44.1.1.2.1.124.124.7.0.25.7.65.190.160.173.0 1026 | 1027 | # Read address 172.0 1028 | snmpget -v1 -d -c public 192.168.1.87 1.3.6.1.4.1.1248.1.2.2.44.1.1.2.1.124.124.7.0.25.7.65.190.160.172.0 1029 | 1030 | # Write 25 to address 173.0 1031 | snmpget -v1 -d -c public 192.168.1.87 1.3.6.1.4.1.1248.1.2.2.44.1.1.2.1.124.124.16.0.25.7.66.189.33.173.0.25.88.98.108.98.117.112.99.106 1032 | 1033 | # Write 153 to address 172.0 1034 | snmpget -v1 -d -c public 192.168.1.87 1.3.6.1.4.1.1248.1.2.2.44.1.1.2.1.124.124.16.0.25.7.66.189.33.172.0.153.88.98.108.98.117.112.99.106 1035 | ``` 1036 | 1037 | ### References 1038 | 1039 | ReInk: (especially ) 1040 | 1041 | epson-printer-snmp: (and ) 1042 | 1043 | ReInkPy: 1044 | 1045 | reink-net: 1046 | 1047 | epson-l4160-ink-waste-resetter: 1048 | 1049 | epson-l3160-ink-waste-resetter: 1050 | 1051 | emanage x900: 1052 | 1053 | Reversing Epson printers: 1054 | 1055 | escputil.c: https://github.com/echiu64/gutenprint/blob/master/src/escputil/escputil.c# 1056 | 1057 | ### Other programs 1058 | 1059 | - Epson One-Time Maintenance Ink Pad Reset Utility: 1060 | - Epson Maintenance Reset Utility: 1061 | - Epson Ink Pads Reset Utility Terms and Conditions: 1062 | - Epson Adjustment Program (developed by EPSON) 1063 | - WIC-Reset: / (Use at your risk) 1064 | - PrintHelp: (Use at your risk) 1065 | 1066 | ### Other resources 1067 | - 1068 | - 1069 | - 1070 | 1071 | ## License 1072 | 1073 | EUPL-1.2 License - See [LICENSE](LICENSE) for details. 1074 | --------------------------------------------------------------------------------