AirPrint on Windows: HP LaserJet P3015dn + Windows-AirPrint-Bridge
Tested on: Windows Server 2025 Core (FS-WINDOWS) and an HP LaserJet P3015dn. Private IPs are examples; replace them with your own.
Corrections since recording: The video cloned
main; this text pinsec504c9(v1.4.1). The video used-Profile Any; the text scopes the firewall toLocalSubnet. The PrintService log, the zero execution-time limit and the supervisor loop were added after filming. The lockfile also adds PyMuPDF, which the recordedrequirements.txtomitted. 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
- Windows Server 2025 Core (tested); Server 2019/2022 are likely similar but unverified.
- Python 3.12 (3.8+ is the bridge’s minimum; 3.12 is what was verified).
- Git for Windows.
- HP Universal Print Driver (UPD) PCL6, version 8.2.0.26819 in the reference.
- Microsoft Visual C++ Redistributable 2015–2022 (x64).
- The printer reachable on the LAN (raw 9100 is enough).
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.
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.
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.
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
- Commercial AirPrint bridges (O’Print, Flyingbee) exist and are easier to install, but they are closed-source. Check the current feature list before buying; AirPrint support has changed between releases. They are a comparison, not a dependency.
- Bonjour Print Services for Windows is a client (it lets Windows see Bonjour printers), not a server. It does not make the P3015dn AirPrint.
- WSL2 can run a Linux CUPS/Avahi bridge, but it adds a virtual network and a second OS to debug. See the Linux post for that approach; it is a fallback, not the shortest path.
- Elpamsoft AirPrint is obsolete (2011/iOS 5 era); do not use it.
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.