Windows 7 The HP Printer Display Hack (with financial goodness)

News

Extraordinary Robot
Robot
Joined
Jun 27, 2006
Location
Chicago, IL
The HP Printer Display Hack is a simple background application that periodically checks the current price of a selected stock and sends it to the display of HP (and compatible) laser printers.
[h=3]Introduction[/h]This app is based on an old hack from back to at least 1997 that uses the HP Job control language to change the text on the LCD status display. Some background on this hack can be found here: http://www.irongeek.com/i.php?page=security/networkprinterhacking. There are various versions of the hack code out there, and typically they all work the same way: you specify the address of the printer and the message to send, open a TCP connection to the printer over port 9100, and then send a command to update the display.
This app is a variation of that hack. It’s a tray application that periodically checks the stock price for a company and then sends a formatted message of the stock symbol and price to a specified printer.
To get the current stock price, we retrieve the data from Yahoo! through finance.yahoo.com. The data comes back in CSV format. To save a step in parsing the CSV columns, we use YQL, the Yahoo! Query Language. Yahoo! created YQL to provide a SQL-like API for querying data from various online web services. YQL! can return XML or JSON data, and we’ll take the XML and use LINQ to parse the data.

[h=4]How to Use the App[/h]The first time you run the app, the main form will appear and you'll be able to enter in the stock symbol and the IP address of your printer. Click the “Get Printer” button to view a dialog listing the available printers connected on port 9100.
There are two checkboxes. The first one is labeled “Start with Windows”. When this setting is saved, the following code is executed to tell Windows whether to start the app when user logs in:

C#

private void StartWithWindows(bool start) { using (RegistryKey hkcu = Registry.CurrentUser) { using (RegistryKey runKey = hkcu.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run", true)) { if (runKey == null) return; if (start) runKey.SetValue(wpfapp.Properties.Resources.Code4FunStockPrinter, Assembly.GetEntryAssembly().Location); else { if (runKey.GetValue(wpfapp.Properties.Resources.Code4FunStockPrinter) != null) runKey.DeleteValue(wpfapp.Properties.Resources.Code4FunStockPrinter); } } } }
VB

Private Sub StartWithWindows(ByVal start As Boolean) Using hkcu As RegistryKey = Registry.CurrentUser Using runKey As RegistryKey = hkcu.OpenSubKey("Software\Microsoft\Windows\CurrentVersion\Run", True) If runKey Is Nothing Then Return End If If start Then runKey.SetValue(My.Resources.Code4FunStockPrinter, System.Reflection.Assembly.GetEntryAssembly().Location) Else If runKey.GetValue(My.Resources.Code4FunStockPrinter) IsNot Nothing Then runKey.DeleteValue(My.Resources.Code4FunStockPrinter) End If End If End Using End UsingEnd SubThe enabled checkbox is used so that you can pause the sending of the stock price to the printer without having to exit the app. When you press the “Start” button, you are prompted to save any changed settings and the app hides the main form, leaving just the system tray icon. While the app is running, it will check the stock price every 5 minutes. If the price has changed, it tells the printer to display the stock symbol and price on the display.
A DispatcherTimer object is used to determine when to check the stock price. It’s created when the main form is created and will only execute the update code when the settings have been defined and enabled.
If an unexpected error occurs, the DispatcherUnhandledException event handler will log the error to a file and alert the user:

C#

void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e) { // stop the timer _mainWindow.StopPrinterHacking(); // display the error _mainWindow.LogText("Sending the stock prince to the printer was halted due to this error:" + e.Exception.ToString()); // display the form ShowMainForm(); // Log the error to a file and notify the user Exception theException = e.Exception; string theErrorPath = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) + "\\PrinterDisplayHackError.txt"; using (System.IO.TextWriter theTextWriter = new System.IO.StreamWriter(theErrorPath, true)) { DateTime theNow = DateTime.Now; theTextWriter.WriteLine(String.Format("The error time: {0} {1}", theNow.ToShortDateString(), theNow.ToShortTimeString())); while (theException != null) { theTextWriter.WriteLine("Exception: " + theException.ToString()); theException = theException.InnerException; } } MessageBox.Show("An unexpected error occurred. A stack trace can be found at:\n" + theErrorPath); e.Handled = true; }
VB

Private Sub App_DispatcherUnhandledException(ByVal sender As Object, ByVal e As System.Windows.Threading.DispatcherUnhandledExceptionEventArgs) ' stop the timer _mainWindow.StopPrinterHacking() ' display the error _mainWindow.LogText("Sending the stock prince to the printer was halted due to this error:" & e.Exception.ToString()) ' display the form ShowMainForm() ' Log the error to a file and notify the user Dim theException As Exception = e.Exception Dim theErrorPath As String = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) & "\PrinterDisplayHackError.txt" Using theTextWriter As System.IO.TextWriter = New System.IO.StreamWriter(theErrorPath, True) Dim theNow As Date = Date.Now theTextWriter.WriteLine(String.Format("The error time: {0} {1}", theNow.ToShortDateString(), theNow.ToShortTimeString())) Do While theException IsNot Nothing theTextWriter.WriteLine("Exception: " & theException.ToString()) theException = theException.InnerException Loop End Using MessageBox.Show("An unexpected error occurred. A stack trace can be found at:" & vbLf & theErrorPath) e.Handled = TrueEnd Sub
[h=4]The User Interface[/h]The application currently looks like this:

Pressing the “Get Printer” button opens a dialog that looks like this:
image_thumb%5B1%5D-3.png

The UI was designed with WPF and uses the basic edit controls as well as a theme from the WPF Themes project on CodePlex. On the main form, the stock symbol, printer IP address, and the check boxes using data bindings to bind each control to a custom setting are defined in the PrinterHackSettings class.
The settings are defined in a class descended from ApplicationSettingsBase. The .NET runtime will read and write the settings based on the rules defined here.
The big RichTextBog in the center of the form is used to display the last 10 stock price updates. The app keeps a queue of the stock price updates, and when the queue is updated it’s sent to the RichTextBox with the following code:

C#

public void UpdateLog(RichTextBox rtb) { int i = 0; TextRange textRange = new TextRange(rtb.Document.ContentStart, rtb.Document.ContentEnd); textRange.Text = string.Empty; foreach (var lg in logs) { i++; TextRange tr = new TextRange(rtb.Document.ContentEnd, rtb.Document.ContentEnd) { Text = String.Format("{0} : ", lg.LogTime.ToString("hh:mm:ss")) }; tr.ApplyPropertyValue(TextElement.ForegroundProperty, Brushes.DarkRed); tr = new TextRange(rtb.Document.ContentEnd, rtb.Document.ContentEnd) { Text = lg.LogMessage + Environment.NewLine }; tr.ApplyPropertyValue(TextElement.ForegroundProperty, Brushes.Black); } if (i > 10) logs.Dequeue(); rtb.ScrollToEnd(); }
[h=4]VB

Public Sub UpdateLog(ByVal rtb As RichTextBox) Dim i As Integer = 0 For Each lg As LogEntry In logs i += 1 Dim tr As New TextRange(rtb.Document.ContentEnd, rtb.Document.ContentEnd) With {.Text = String.Format("{0} : ", lg.LogTime.ToString("hh:mm:ss"))} tr.ApplyPropertyValue(TextElement.ForegroundProperty, Brushes.Red) tr = New TextRange(rtb.Document.ContentEnd, rtb.Document.ContentEnd) With {.Text = lg.LogMessage & Environment.NewLine} tr.ApplyPropertyValue(TextElement.ForegroundProperty, Brushes.White) Next lg If i > 10 Then logs.Dequeue() End If rtb.ScrollToEnd()End Sub[/h][h=4]Displaying a notification trace icon[/h]WPF does not provide any functionality for running an app with just an icon in the notification area of the taskbar. We need to tap into some WinForms functionality. Add a reference to the System.Windows.Form namespace to the project. In the App.xaml file, add an event handler to the Startup event. Visual Studio will wire up an Application.Startup event in the code behind file. We can use that event to add a WinForms.NotifyIcon and wireup a context menu to it:

C#

private void Application_Startup(object sender, StartupEventArgs e) { _notifyIcon = new WinForms.NotifyIcon(); _notifyIcon.DoubleClick += notifyIcon_DoubleClick; _notifyIcon.Icon = wpfapp.Properties.Resources.Icon; _notifyIcon.Visible = true; WinForms.MenuItem[] items = new[] { new WinForms.MenuItem("&Settings", Settings_Click) { DefaultItem = true } , new WinForms.MenuItem("-"), new WinForms.MenuItem("&Exit", Exit_Click) }; _notifyIcon.ContextMenu = new WinForms.ContextMenu(items); _mainWindow = new MainWindow(); if (!_mainWindow.SettingsAreValid()) _mainWindow.Show(); else _mainWindow.StartPrinterHacking(); }
VB

Private Sub Application_Startup(ByVal sender As Object, ByVal e As StartupEventArgs) _notifyIcon = New System.Windows.Forms.NotifyIcon() AddHandler _notifyIcon.DoubleClick, AddressOf notifyIcon_DoubleClick _notifyIcon.Icon = My.Resources.Icon _notifyIcon.Visible = True Dim items() As System.Windows.Forms.MenuItem = {New System.Windows.Forms.MenuItem("&Settings", AddressOf Settings_Click) With {.DefaultItem = True}, New System.Windows.Forms.MenuItem("-"), New System.Windows.Forms.MenuItem("&Exit", AddressOf Exit_Click)} _notifyIcon.ContextMenu = New System.Windows.Forms.ContextMenu(items) _mainWindow = New MainWindow() If Not _mainWindow.SettingsAreValid() Then _mainWindow.Show() Else _mainWindow.StartPrinterHacking() End IfEnd Sub
[h=4]Getting the Stock Information[/h]From the Yahoo Financial site, you get can download a CSV file for any specified stock. Here's a web site that documents the format needed to get the right fields: http://www.gummy-stuff.org/Yahoo-data.htm. We want to return the stock symbol and the last traded price. That works out to be “s” and “l1”, respectively.
If you open the following URL with a browser, a file named quotes.csv will be returned:
http://download.finance.yahoo.com/d/quotes.csv?s=MSFT&f=sl1
You should get a file like this:
quotes_csv_thumb.png

The first field is the stock symbol and the second is the last recorded price. You could just read that data and parse out the fields, but we can get the data in more readable format.
Yahoo! has a tool called the YQL Console that will you let you interactively query against Yahoo! and other web service providers. While it's overkill to use on a two column CSV file, it can be used to tie together data from multiple services.
To use our MSFT stock query with YQL, we format the query like this:


select * from csv where url='http://download.finance.yahoo.com/d/quotes.csv?s=MSFT&f=sl1' and columns='symbol,price'You can see this query loaded into the YQL Console here.

When you click the “TEST” button, the YQL query is executed and the results displayed in the lower panel. By default, the results are in XML, but you can also get the data back in JSON format.
Our result set has been transformed into the following XML:


MSFT
30.54
This XML document can be easily parsed in the application code. The URL listed below “THE REST QUERY” on the YQL page is the YQL query encoded so that it can be sent as a GET request. For this YQL query, we use the following URL:
http://query.yahooapis.com/v1/public/yql?q=select%20*%20from%20csv%20where%20url%3D'http%3A%2F%2Fdownload.finance.yahoo.com%2Fd%2Fquotes.csv%3Fs%3DMSFT%26f%3Dsl1'%20and%20columns%3D'symbol%2Cprice'
This is the URL that our application uses to get the stock price. Notice the MSFT in bold face—we replace that hard coded stock symbol with a format item and just use String.Format() to generate the URL at run time.
To get the stock price from our code, we can wrap this with the following method:

C#

public string GetPriceFromYahoo(string tickerSymbol) { string price = string.Empty; string url = string.Format("http://query.yahooapis.com/v1/public/yql?q=select%20*%20from%20csv%20where%20url%3D'http%3A%2F%2Fdownload.finance.yahoo.com%2Fd%2Fquotes.csv%3Fs%3D{0}%26f%3Dsl1'%20and%20columns%3D'symbol%2Cprice'", tickerSymbol); try { Uri uri = new Uri(url); HttpWebRequest req = (HttpWebRequest)WebRequest.Create(uri); HttpWebResponse resp = (HttpWebResponse)req.GetResponse(); XDocument doc = XDocument.Load(resp.GetResponseStream()); resp.Close(); var ticker = from query in doc.Descendants("query") from results in query.Descendants("results") from row in query.Descendants("row") select new { price = row.Element("price").Value }; price = ticker.First().price; } catch (Exception ex) { price = "Exception retrieving symbol: " + ex.Message; } return price; }
VB

Public Function GetPriceFromYahoo(ByVal tickerSymbol As String) As String Dim price As String Dim url As String = String.Format("http://query.yahooapis.com/v1/public/yql?q=select%20*%20from%20csv%20where%20url%3D'http%3A%2F%2Fdownload.finance.yahoo.com%2Fd%2Fquotes.csv%3Fs%3D{0}%26f%3Dsl1'%20and%20columns%3D'symbol%2Cprice'", tickerSymbol) Try Dim uri As New Uri(url) Dim req As HttpWebRequest = CType(WebRequest.Create(uri), HttpWebRequest) Dim resp As HttpWebResponse = CType(req.GetResponse(), HttpWebResponse) Dim doc As XDocument = XDocument.Load(resp.GetResponseStream()) resp.Close() Dim ticker = From query In doc.Descendants("query") , results In query.Descendants("results") , row In query.Descendants("row") _ Let xElement = row.Element("price") _ Where xElement IsNot Nothing _ Select New With {Key .price = xElement.Value} price = ticker.First().price Catch ex As Exception price = "Exception retrieving symbol: " & ex.Message End Try Return priceEnd Function
While this code makes the readying of a two column CSV file more complicated than it needs to be, it makes it easier to adapt this code to read the results for multiple stock symbols and/or additional fields.
[h=4]Getting the List of Printers[/h]We are targeting a specific type of printer: those that use the HP PJL command set. Since we talk to these printers over port 9100, we only need to list the printers that listen on that port. We can use Windows Management Instrumentation (WMI) to list the printer TCP/IP addresses that are using port 9100. The WMI class Win32_TCPIPPrinterPort can be used for that purpose, and we’ll use the following WMI query:
Select Name, HostAddress from Win32_TCPIPPrinterPort where PortNumber = 9100
This returns the list of port names and addresses on your computer that are being used over port 9100. Take that list and store it in a dictionary for a quick lookup:

C#

static public Dictionary GetPrinterPorts(){ var ports = new Dictionary(); ObjectQuery oquery = new ObjectQuery("Select Name, HostAddress from Win32_TCPIPPrinterPort where PortNumber = 9100"); ManagementObjectSearcher mosearcher = new ManagementObjectSearcher(oquery); using (var searcher = new ManagementObjectSearcher(oquery)) { var objectCollection = searcher.Get(); foreach (ManagementObject managementObjectCollection in objectCollection) { var portAddress = IPAddress.Parse(managementObjectCollection.GetPropertyValue("HostAddress").ToString()); ports.Add(managementObjectCollection.GetPropertyValue("Name").ToString(), portAddress); } } return ports; }
VB

Public Shared Function GetPrinterPorts() As Dictionary(Of String, IPAddress) Dim ports = New Dictionary(Of String, IPAddress)() Dim oquery As New ObjectQuery("Select Name, HostAddress from Win32_TCPIPPrinterPort where PortNumber = 9100") Dim mosearcher As New ManagementObjectSearcher(oquery) Using searcher = New ManagementObjectSearcher(oquery) Dim objectCollection = searcher.Get() For Each managementObjectCollection As ManagementObject In objectCollection Dim portAddress = IPAddress.Parse(managementObjectCollection.GetPropertyValue("HostAddress").ToString()) ports.Add(managementObjectCollection.GetPropertyValue("Name").ToString(), portAddress) Next managementObjectCollection End Using Return portsEnd Function
Next, we get the list of printers that this computer knows about. We could do that through WMI, but I decided to stay closer to the .NET Framework and use the LocalPrintServer class. The GetPrintQueues method returns a collection of print queues of the type PrintQueueCollection. We can then iterate through the PrintQueueCollection and look for all printers that have a port name that matches the names returned by the WMI query. That gives code that looks like this:

C#

public class LocalPrinter { public string Name { get; set; } public string PortName { get; set; } public IPAddress Address { get; set; } } static public List GetPrinters() { Dictionary ports = GetPrinterPorts(); EnumeratedPrintQueueTypes[] enumerationFlags = { EnumeratedPrintQueueTypes.Local }; LocalPrintServer printServer = new LocalPrintServer(); PrintQueueCollection printQueuesOnLocalServer = printServer.GetPrintQueues(enumerationFlags); return (from printer in printQueuesOnLocalServer where ports.ContainsKey(printer.QueuePort.Name) select new LocalPrinter() { Name = printer.Name, PortName = printer.QueuePort.Name, Address = ports[printer.QueuePort.Name] }).ToList(); }
[h=4]VB

Public Class LocalPrinter Public Property Name() As String Public Property PortName() As String Public Property Address() As IPAddressEnd ClassPublic Shared Function GetPrinters() As List(Of LocalPrinter) Dim ports As Dictionary(Of String, IPAddress) = GetPrinterPorts() Dim enumerationFlags() As EnumeratedPrintQueueTypes = { EnumeratedPrintQueueTypes.Local } Dim printServer As New LocalPrintServer() Dim printQueuesOnLocalServer As PrintQueueCollection = printServer.GetPrintQueues(enumerationFlags) Return ( _ From printer In printQueuesOnLocalServer _ Where ports.ContainsKey(printer.QueuePort.Name) _ Select New LocalPrinter() With {.Name = printer.Name, .PortName = printer.QueuePort.Name, .Address = ports(printer.QueuePort.Name)}).ToList()End Function[/h][h=4]Sending the Stock Price to the Printer[/h]The way to send a message to a HP display is via a PJL command. PJL stands for Printer Job Language. Not all PJL commands are recognized by every HP printer, but if you have an HP laser printer with a display, the command should work. This should work for any printer that is compatible with HP’s PJL command set. For the common PJL commands, HP has an online document here.
We will be using the “Ready message display” PJL command. All PJL commands will start and end with a sequence of bytes called the “Universal Exit Language” or UEL. This sequence tells the printer that it’s about to receive a PJL command. The UEL is defined as
%-12345X
The format of the packet sent to the printer is be "UEL PJL command UEL". The Ready message display format is
@PJL RDYMSG DISPLAY=”message”[]
To send the command that has the printer display “Hello World”, you would send the following sequence:
%-12345X@PJL RDYMSG DISPLAY=”Hello World”[]%-12345X[]
We wrap this up in a class called SendToPrinter and the good stuff gets executed in the Send method, as listed below:

C#

public class SendToPrinter { public string host { get; set; } public int Send(string message) { IPAddress addr = null; IPEndPoint endPoint = null; try { addr = Dns.GetHostAddresses(host)[0]; endPoint = new IPEndPoint(addr, 9100); } catch (Exception e) { return 1; } Socket sock = null; String head = "\u001B%-12345X@PJL RDYMSG DISPLAY = \""; String tail = "\"\r\n\u001B%-12345X\r\n"; ASCIIEncoding encoding = new ASCIIEncoding(); try { sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP); sock.Connect(endPoint); sock.Send(encoding.GetBytes(head)); sock.Send(encoding.GetBytes(message)); sock.Send(encoding.GetBytes(tail)); sock.Close(); } catch (Exception e) { return 1; } int bytes = (head + message + tail).Length; return 0; } }
VB

Public Function Send(ByVal message As String) As Integer Dim endPoint As IPEndPoint = Nothing Try Dim addr As IPAddress = Dns.GetHostAddresses(Host)(0) endPoint = New IPEndPoint(addr, 9100) Catch Return 1 End Try Dim startPJLSequence As String = ChrW(&H1B).ToString() & "%-12345X@PJL RDYMSG DISPLAY = """ Dim endPJLSequence As String = """" & vbCrLf & ChrW(&H1B).ToString() & "%-12345X" & vbCrLf Dim encoding As New ASCIIEncoding() Try Dim sock As New Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP) sock.Connect(endPoint) sock.Send(encoding.GetBytes(startPJLSequence)) sock.Send(encoding.GetBytes(message)) sock.Send(encoding.GetBytes(endPJLSequence)) sock.Close() Catch Return 1 End Try Return 0End Function
[h=4]The Installer[/h]The installer for this app was written with WiX, Windows Installer XML. WiX is an open source project created by Rob Mensching that lets you build Windows Installer .msi and .msm files from XML source code. I used the release candidate of WiX 3.6, but any recent version should work. Of course, you don’t need an installer if you build the app yourself.
Setting InstalScope to “perUser” designates this package as being a per-user install. Adding the property “WixAppFolder” and set to “WixPerUserFolder” tells WiX to install this app under %LOCALAPPDATA% instead of under %ProgramFiles%. This eliminates the need for the installer to request elevated rights and the UAC prompt:





Because we are not touching any system settings, I eliminated the creation of a system restore point at the start of the installation process. This greatly speeds up the installation of the app, and is handled by adding a property named MSIFASTINSTALL with the value of “1”:



I modified the UI sequence to skip over the end user license agreement. There is nothing to license here and no one reads EULAs anyways. To do this, I needed to download the WiX source code and extract a file named WixUI_Mondo.wxs. I added it to the installer project and renamed it to WixUI_MondoNoLicense.wxs. I also added a checkbox to the exit dialog to allow the user to launch the app after it been installed:





WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed
When you build the installer, it generates two ICE91 warning messages. An ICE91 warning occurs when you install a file or shortcut into a per-user only folder. Since we have explicitly set the InstallScope to “perUser”, we can safely ignore these two warnings. If you hate warning messages, you can use the tool settings for the installer project to suppress ICE91 validation checks:
toolsettings_thumb.png

[h=3]Conclusion[/h]I have had various versions of this app running in my office for over a year. It’s been set to show our current stock price on the main printer in the development department. It’s fun to watch people walk near the printer just to check out the current stock price.
If you want to try this out, the download link for the source code and installer is at the top of the article!
[h=3]About The Author[/h]I am a senior R&D engineer for Tyler Technologies, working on our next generation of school bus routing software. I also am the leader of the Tech Valley .NET Users Group (TVUG). You can follow me at @anotherlab and check out my blog at anotherlab.rajapet.net. I would list my G+ address, but I don’t use it. I started out with a VIC-20 and been slowly moving up the CPU food chain ever since.
I would like to thank Brian Peek on the Coding4Fun team for his encouragement and suggestions and for letting me steal large chunks of the UI code from his TweeVo project .
njs.gif


More...
 
Back
Top Bottom