Anders Tall

AirPrint on Windows: HP LaserJet P3015dn + Windows-AirPrint-Bridge

14 min read

Tested on: Windows Server 2025 Core (FS-WINDOWS) and an HP LaserJet P3015dn. Private IPs are examples; replace them with your own.

Same printer, another OS: Linux · macOS · Windows

Corrections since recording: The video cloned main; this text pins ec504c9 (v1.4.1). The video used -Profile Any; the text scopes the firewall to LocalSubnet. The PrintService log, the zero execution-time limit and the supervisor loop were added after filming. The lockfile also adds PyMuPDF, which the recorded requirements.txt omitted. Use the text.

An HP LaserJet P3015dn is a network printer. It has Ethernet, automatic duplex, and a built-in JetDirect. What it does not have is AirPrint — so the iPhone’s print sheet ignores it, even though a Windows box on the same LAN can print to it all day.

The fix is a small Windows bridge. It speaks IPP and mDNS on one side and the printer’s raw socket (or a PCL6 queue) on the other. This page is the why and the exact commands; the videos show the same steps.

The printer is already on the network

The P3015dn (product number CE528A) sits at 192.168.2.83. Its JetDirect advertises IPP over mDNS, but the TXT record has no URF and the pdl list has no image/urf. iOS does not fall back to PostScript or PCL; it wants the AirPrint raster (URF) or a driverless IPP printer that speaks PDF/PWG-Raster. That is why the printer appears in Windows but not in the iPhone’s print sheet.

The bridge: Windows-AirPrint-Bridge

Windows-AirPrint-Bridge is a GPLv3 Python project. It registers _ipp._tcp and _universal._sub._ipp._tcp over mDNS, accepts IPP on TCP 631, converts the incoming PDF/URF/JPEG/PNG job, and spools it to the default Windows printer. It is the piece Windows itself does not ship: a local AirPrint advertiser.

The reference runs on Windows Server 2025 Core at 192.168.2.107, with the P3015dn at 192.168.2.83. The computer name is fs-windows; that is where the (fs-windows) suffix in the Bonjour name comes from. Windows 10 reached end of support in October 2025, so this post targets Server 2025 Core. Windows 11 is likely similar but was not tested here.

What you need

1. Python and Git

Invoke-WebRequest https://www.python.org/ftp/python/3.12.10/python-3.12.10-amd64.exe -OutFile C:\python.exe
Start-Process C:\python.exe -ArgumentList "/quiet InstallAllUsers=1 PrependPath=1 Include_pip=1" -Wait

Invoke-WebRequest https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.1/Git-2.47.1-64-bit.exe -OutFile C:\git.exe
Start-Process C:\git.exe -ArgumentList "/VERYSILENT /NORESTART" -Wait

Open a new shell so python and git are on PATH, then check:

python --version
git --version

2. Clone the bridge and install its dependencies

git clone https://github.com/salmanasmat/Windows-AirPrint-Bridge C:\AirPrintBridge
Set-Location C:\AirPrintBridge
git checkout ec504c9  # v1.4.1, the version this post was verified with
@'
ifaddr==0.2.0
pillow==13.0.0
pymupdf==2.0
pywin32==312
zeroconf==0.152.0
'@ | Set-Content C:\AirPrintBridge\requirements.lock
python -m pip install -r C:\AirPrintBridge\requirements.lock
python "C:\Program Files\Python312\Scripts\pywin32_postinstall.py" -install

The lock contains the five packages the bridge imports. The repository’s requirements.txt omits PyMuPDF, so installing only that file gives a bridge that appears on the iPhone but fails every print with No module named 'fitz'. The lock above is the pip freeze from FS-WINDOWS on 2026-11-12, trimmed to the bridge’s own dependencies; it is not a claim that every package version was exercised in a separate lab run.

PyMuPDF’s package name is pymupdf; pip install fitz is a different, unrelated package. Adding pymupdf to the bridge’s requirements.txt is worth an upstream issue or PR.

ec504c9 is pinned deliberately: main has moved since the recording. If you want the newer build, use its release tag and re-check the format list; PWG Raster decoding was added after v1.4.1.

The pywin32_postinstall step is easy to miss. Without it, the service helpers and some Win32 modules are not registered.

The project also ships AirPrintBridge_Setup_v1.4.1.exe, which installs the bridge as a Windows service. This post builds from source because the source is reviewable and the steps are explicit; the installer is a reasonable choice if you prefer a packaged service.

3. The printer driver: use HP UPD PCL6

This is the step that decides whether the bridge can actually print. The P3015dn is a PCL5/PCL6 printer. On Server Core, a queue with Microsoft IPP Class Driver on the raw port spools the job but fails in the print processor:

The print job was sent through the print processor MS_XPS_PROC ...
Win32 error code returned by the print processor: 0x7E.

0x7E is “module not found”; the IPP class driver’s XPS path is not complete on Server Core. The fix is HP’s PCL6 driver on the same raw port.

Download and expand the HP UPD package (the exact 8.2.0.26819 build used here):

Invoke-WebRequest https://ftp.hp.com/pub/softlib/software13/printers/UPD/upd-pcl6-win11-x64-8.2.0.26819.zip -OutFile C:\upd-pcl6.zip
Expand-Archive C:\upd-pcl6.zip C:\upd-pcl6 -Force

A fresh Server Core may reject the signed catalog with “The publisher of an Authenticode(tm) signed catalog has not yet been established as trusted.” Import the HP signer certificate, then stage the driver:

$sig = Get-AuthenticodeSignature C:\upd-pcl6\hpcu360u.cat
if ($sig.Status -ne 'Valid') { throw "Unexpected signature status: $($sig.Status)" }
$sig.SignerCertificate.Subject
Export-Certificate -Cert $sig.SignerCertificate -FilePath C:\hp-signer.cer -Force
Import-Certificate -FilePath C:\hp-signer.cer -CertStoreLocation Cert:\LocalMachine\TrustedPublisher
pnputil /add-driver C:\upd-pcl6\hpcu360u.inf /install

Then add the driver and the queue. If the queue already exists with the Microsoft driver, use Set-Printer instead of Add-Printer:

Add-PrinterDriver -Name "HP Universal Printing PCL 6"

Add-PrinterPort -Name "P3015-9100" -PrinterHostAddress "192.168.2.83" -PortNumber 9100
Add-Printer -Name "HP LaserJet P3015dn" -DriverName "HP Universal Printing PCL 6" -PortName "P3015-9100"

# If the queue already exists:
# Set-Printer -Name "HP LaserJet P3015dn" -DriverName "HP Universal Printing PCL 6"

The queue name is what the bridge advertises. Keep it short and recognizable; HP LaserJet P3015dn is what the iPhone shows with (fs-windows) appended.

4. The Visual C++ runtime (win32ui)

The bridge spools PDFs through win32ui and the Windows GDI. Server Core does not include the MFC runtime by default, so the first IPP print job can fail with:

Missing dependency for printing: DLL load failed while importing win32ui:
The specified module could not be found.

Install the Visual C++ Redistributable and test the import:

Invoke-WebRequest https://aka.ms/vs/17/release/vc_redist.x64.exe -OutFile C:\vc_redist.x64.exe
Start-Process C:\vc_redist.x64.exe -ArgumentList "/install /quiet /norestart" -Wait
python -c "import win32ui; print('win32ui OK')"

5. The first cut: a local test page

Before involving the iPhone, prove the Windows queue itself prints:

wevtutil sl Microsoft-Windows-PrintService/Operational /e:true
"Test page $(Get-Date)" | Out-Printer -Name "HP LaserJet P3015dn"
Get-PrintJob -PrinterName "HP LaserJet P3015dn"
Get-WinEvent -LogName "Microsoft-Windows-PrintService/Operational" -MaxEvents 5

The PrintService/Operational log is disabled by default; enable it before expecting events.

The event log should show the job printed through P3015-9100, with the hpcpp360 print processor and Win32 error 0x0. If it does, the printer side is real.

Watch on YouTube →

6. The SYSTEM default printer

The bridge calls win32print.GetDefaultPrinter() at startup. A fresh SYSTEM account has no HKCU\Software\Microsoft\Windows NT\CurrentVersion\Windows key at all, so SetDefaultPrinter fails with 0x80070002 and the bridge aborts with The default printer was not found. Create the key first:

The startup script below creates this key in the SYSTEM profile. Do not use New-Item -Force interactively; it deletes and recreates the key, wiping the administrator’s existing values. To see what the bridge actually reads, use:

Get-ItemProperty "Registry::HKEY_USERS\S-1-5-18\Software\Microsoft\Windows NT\CurrentVersion\Windows" -Name Device

This is per-user state. If the bridge runs as SYSTEM, set it in the SYSTEM context (the scheduled task below runs the setup script as SYSTEM). On bridge v1.4.2+ you can instead set printer_name in config.json; it takes precedence over the default printer, and this registry step becomes optional.

7. Firewall: IPP and mDNS

Windows Firewall blocks both by default. Allow TCP 631 and UDP 5353 on the LAN. The rules below are scoped to Private/Domain and LocalSubnet; make sure the LAN adapter is classified as Private:

Find the LAN adapter first; Ethernet 2 is the lab’s name, not a universal one:

Get-NetConnectionProfile

Then use that InterfaceAlias below:

New-NetFirewallRule -DisplayName "AirPrint IPP" -Direction Inbound -Protocol TCP -LocalPort 631 -Action Allow -Profile Private,Domain -RemoteAddress LocalSubnet
New-NetFirewallRule -DisplayName "AirPrint mDNS" -Direction Inbound -Protocol UDP -LocalPort 5353 -Action Allow -Profile Private,Domain -RemoteAddress LocalSubnet
# Lab-specific: replace "Ethernet 2" with your LAN adapter from Get-NetConnectionProfile
Set-NetConnectionProfile -InterfaceAlias "Ethernet 2" -NetworkCategory Private

The adapter must end up Private; otherwise the rules above do not apply. The LocalSubnet scope keeps the printer service off other networks.

8. Run the bridge at boot

Running python airprint_bridge.py in an SSH session works until you log out; then the process dies. Make it a scheduled task at startup, as SYSTEM, that sets the default printer and starts the bridge. Put this helper in C:\system-setup.ps1:

system-setup.ps1

# system-setup.ps1 — verified 2026-10-31 on FS-WINDOWS (Windows Server 2025 Core)
# The supervisor loop and zero execution-time limit were added after that verification.
# Scheduled task "AirPrint Bridge" runs this at startup as SYSTEM.
# It is idempotent: printer/driver, SYSTEM default printer, then the bridge.
# Copy to C:\system-setup.ps1; the task references that path.

$ErrorActionPreference = "Continue"
$log = "C:\AirPrintBridge\system-setup.log"
"=== $(Get-Date) ===" | Out-File $log -Append
"whoami: $(whoami)" | Out-File $log -Append

$printerName = "HP LaserJet P3015dn"
$portName = "P3015-9100"
$driverName = "HP Universal Printing PCL 6"

# Pin the LAN NIC as the default-route source. The bridge's get_local_ip()
# reads the routing table; without this it can pick the lab NIC (10.10.10.51)
# after a reboot, and mDNS then advertises an address the iPhone cannot reach.
# The interface aliases below are lab-specific: check Get-NetConnectionProfile
# and Get-NetIPInterface and replace "Ethernet 2" / "Ethernet" with yours.
Set-NetIPInterface -InterfaceAlias "Ethernet 2" -InterfaceMetric 10 -ErrorAction SilentlyContinue
Set-NetIPInterface -InterfaceAlias "Ethernet" -InterfaceMetric 20 -ErrorAction SilentlyContinue

Add-PrinterPort -Name $portName -PrinterHostAddress "192.168.2.83" -PortNumber 9100 -ErrorAction SilentlyContinue

$existing = Get-Printer -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq $printerName }
if ($existing) {
    "Printer exists: $($existing.Name) driver=$($existing.DriverName)" | Out-File $log -Append
    if ($existing.DriverName -ne $driverName) {
        try {
            Set-Printer -Name $printerName -DriverName $driverName -ErrorAction Stop
            "Set driver to $driverName OK" | Out-File $log -Append
        } catch { "Set driver: $_" | Out-File $log -Append }
    }
} else {
    "Creating printer $printerName with $driverName" | Out-File $log -Append
    try {
        Add-Printer -Name $printerName -DriverName $driverName -PortName $portName -ErrorAction Stop
    } catch { "Add-Printer: $_" | Out-File $log -Append }
}

$key = "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Windows"
if (-not (Test-Path $key)) { New-Item -Path $key | Out-Null }
try {
    Set-ItemProperty -Path $key -Name "Device" -Value "$printerName,winspool,$portName" -Type String -Force -ErrorAction Stop
    "Set Device OK" | Out-File $log -Append
} catch { "Set Device: $_" | Out-File $log -Append }
try {
    Set-ItemProperty -Path $key -Name "LegacyDefaultPrinterMode" -Value 1 -Type DWord -Force -ErrorAction Stop
    "Set LegacyDefaultPrinterMode OK" | Out-File $log -Append
} catch { "Set Legacy: $_" | Out-File $log -Append }
try {
    (New-Object -ComObject WScript.Network).SetDefaultPrinter($printerName)
    "SetDefaultPrinter OK" | Out-File $log -Append
} catch { "SetDefaultPrinter: $_" | Out-File $log -Append }

Get-Printer | Where-Object { $_.Name -eq $printerName } | Select-Object Name,DriverName,PortName | Out-File $log -Append
Get-ItemProperty $key -Name Device -ErrorAction SilentlyContinue | Out-File $log -Append

Set-Location C:\AirPrintBridge
while ($true) {
    & "C:\Program Files\Python312\python.exe" "C:\AirPrintBridge\airprint_bridge.py" *>> "C:\AirPrintBridge\system-run.log"
    Start-Sleep -Seconds 5
}

Register it with a zero execution-time limit. The default is 72 hours (PT72H), which would silently stop the bridge after three days:

$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-ExecutionPolicy Bypass -File C:\system-setup.ps1"
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName "AirPrint Bridge" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force
Start-ScheduledTask -TaskName "AirPrint Bridge"
(Get-ScheduledTask -TaskName "AirPrint Bridge").Settings.ExecutionTimeLimit

The task starts airprint_bridge.py in the foreground; the task stays Running while the bridge is alive. The loop above restarts a crashed bridge after five seconds. The loop and the zero execution-time limit are newer than the 2026-10-31 verification; test them before relying on them.

9. Verify the AirPrint advertisement

On the Windows side:

Get-ScheduledTask -TaskName "AirPrint Bridge"
Get-NetTCPConnection -LocalPort 631
Get-Content C:\AirPrintBridge\airprint_bridge.log -Tail 20

The log should show:

Detected local IP address: 192.168.2.107
Default Windows printer: HP LaserJet P3015dn
mDNS service registered: HP LaserJet P3015dn (fs-windows)._ipp._tcp.local.
mDNS subtype registered: _universal._sub._ipp._tcp.local.
IPP server listening on 0.0.0.0:631
Server thread started — ready to accept print jobs.

From a Linux host on the same LAN, ipptool is the fastest sanity check:

ipptool -tv ipp://192.168.2.107:631/ipp/print /usr/share/cups/ipptool/get-printer-attributes.test

You want status-code = successful-ok, ipp-features-supported containing airprint-1.8, a urf-supported line, and document-format-supported containing image/urf. Those are the bridge’s IPP capabilities. iOS discovers the queue through DNS-SD (_ipp._tcp plus _universal), not through these attributes. Test the port from a different LAN host; testing your own IP does not exercise the firewall.

Watch on YouTube →

10. Print from the iPhone

On the iPhone: Print → choose HP LaserJet P3015dn (fs-windows) → Print. The job goes to the bridge over IPP, the bridge converts it, and the Windows queue sends PCL6 to the JetDirect on port 9100. A one-page document is the proof; a PDF with several pages is a better stress test.

The bridge log shows the request and the result:

POST /ipp/print ... operation=0x0002
Temp file written: C:\WINDOWS\TEMP\airprint_....pdf
Spooling ... to printer 'HP LaserJet P3015dn'
Successfully spooled to printer.

11. Reboot

The point of the scheduled task is that the next reboot does not break it:

Restart-Computer -Force

After the VM is back, reconnect and check. Run the port test from a different LAN host; testing the Windows host’s own IP does not exercise its firewall:

Get-ScheduledTask -TaskName "AirPrint Bridge"
Get-NetTCPConnection -LocalPort 631
Get-Content C:\AirPrintBridge\airprint_bridge.log -Tail 12

From Linux or macOS on the same LAN:

nc -vz 192.168.2.107 631

From another Windows host:

Test-NetConnection 192.168.2.107 -Port 631

Then print from the iPhone again. If the page comes out, the bridge is done.

Watch on YouTube →

Troubleshooting

The job spools but nothing prints, MS_XPS_PROC 0x7E. The queue is using the Microsoft IPP Class Driver on the raw port. Use HP UPD PCL6 on the same raw port (or an IPP port that the printer actually speaks). The P3015dn supports both 9100 and 631; the lab stayed on 9100.

DLL load failed while importing win32ui. Install the Visual C++ Redistributable and re-test python -c "import win32ui". The bridge’s PDF path needs it.

Missing dependency for printing: No module named ‘fitz’. PyMuPDF is missing. Install the package named pymupdf, not fitz: python -m pip install pymupdf. The repo’s v1.4.1 requirements.txt does not list it; use the lock above or install it explicitly.

The default printer was not found / 0x80070002. The SYSTEM profile has no HKCU\...\Windows key. Create it and run SetDefaultPrinter in the same context as the bridge. Setting it in your interactive Administrator session is not enough.

The iPhone does not see the printer. Check, in order: the bridge task is Running; Get-NetTCPConnection -LocalPort 631 shows Listen; TCP 631 is reachable from another LAN host; UDP 5353 is allowed; the iPhone is on the same LAN and not on a guest/isolated SSID; the bridge log has the mDNS registration line. mDNS does not cross VLANs or client isolation without a reflector.

631 is open locally but closed from another host. The Windows firewall profile is probably Public, or the rule is scoped to Private. Check Get-NetConnectionProfile and the rule’s Profile. In the lab the rule was set to Any to remove that variable.

After a reboot the bridge advertises 10.10.10.51, not the LAN IP. get_local_ip() reads the routing table; it does not send traffic to 8.8.8.8. With two NICs and a default gateway on both, Windows may pick the wrong one. Set the LAN interface metric lower (Set-NetIPInterface -InterfaceAlias "Ethernet 2" -InterfaceMetric 10) and the other NIC higher (Ethernet 20), or remove its default gateway. system-setup.ps1 applies this at boot. If no default route is found, the bridge can fall back to 127.0.0.1, which is just as unreachable from the iPhone.

The HP driver will not stage. A fresh Server Core may not trust the HP catalog. Import the signer certificate into Cert:\LocalMachine\TrustedPublisher and retry pnputil. The signature is valid; it is a trust-store issue, not a corrupt download.

The bridge dies when the SSH session closes. It is running in the foreground of that session. Use the scheduled task (or a Windows service), not an interactive SSH command.

Security

The bridge is GPLv3; read the source and pin a commit before exposing it to a real network. It parses PDFs from every host that can reach port 631 and runs as SYSTEM, so treat it as a network service with real privileges: keep the firewall scoped to the LAN, do not forward TCP 631 or UDP 5353, and use a dedicated host or VM if you can. The P3015dn’s embedded web server is old (TLS 1.0/SSLv3); treat the printer as a trusted LAN device, not an internet-facing one.

Alternatives, briefly

The series

The playlist: AirPrint: Windows →

Three short videos: install the bridge and the PCL6 driver, make Windows advertise AirPrint over mDNS and IPP, and print from an iPhone — including after a reboot.

#airprint #windows #windows-server #hp-laserjet #p3015dn #jetdirect #printing #self-hosting #python