Gav
Post banner image

Smartype Keyboard "Weather Offline" Fix

The Smartype USB keyboard was introduced by KEYVIEW in 2012. It features a built-in display capable of showing a live view of the current line when text editing or a dashboard comprising of a few applets:

  • The current time
  • The current calendar date
  • The current weather conditions
  • The latest email and unread count from a specified mailbox
  • Images from a specified directory

The Backstory

I picked up one of these keyboards from a charity shop as it looked a wee bit interesting and was relatively cheap. Upon plugging the it in and glancing the instructions printed in the box it became apparent that the company providing the software had long gone and the driver / software were no longer being hosted on their site.

Smartype box inside view

Luckily after a bit of Googling I found a link to a download page via the waybackmachine and was able to install the software. (I'll pop a copy of the smartype software here for safe keeping). After configuring the email and gallery features successfully realized that the live weather feature was a not functioning 😔

Smartype keyboard, non-functional weather applet

The Fix

I fired up Wireshark to see what was going on. It turns out that the software was trying to get weather data from a weather webservice API called 'worldweatheronline' using a premium account.

Smartype keyboard, weather API access denied

I could see the 401 status code and error message in the response frame and opeining it up in the browser confirmed that the API account's key had expired, probably because KEYVIEW stopped paying the bill which is fair enough.

Not to worry though, I signed up for a free trial of the API and had a look at the data that was being returned.

Smartype keyboard, weather API success response

From the original request query params (viewed in wireshark) I could see that the software was requesting XML so I made sure to only look at the XML variant of the API response (it can also provide JSON). Thereafter I used an app called Fiddler (link) to capture the request to the API and spoof the response with the XML I'd got manually. To my surprise it worked.

Smartype keyboard, Fiddler fake API response

Although at nearly 1,000 lines of XML this seemed like an awful lot of redundant data considering the only fields the keyboard seemed to need were the temperature and the weather description So after a bit of experimenting I found the minimum response needed to make it work:

Smartype keyboard, weather API minimum needed response

Much better 😊 This seemed like a good start to a solution. It would have been possible to remap the request to use my new API key, but the free trial would only last for a certain amount of time and I didn't fancy paying a monthly subscription as this is just for fun afterall. Instead I had an idea to redirect the requests to a local server where I could then use an alternative free weather API openweathermap.org to get the data I need and send that back to the keyboard. The app didn't have to do anything to complicated so I figured a lightweight node JS server would suffice:

Note I'm still very new to Node JS Server's at this point and so this code could almost certainly be improved (feel free to make a pull request!). Coming from a PHP background I had to spend a bit of time figuring out how to shoehorn my synchronous ideas into the node libraries asynchronous patterns. As the response for the intercepted request is dependant on the response from the open weather API it would have been nice to use a synchronous flow, alas it seems everything in Node JS wants to be async, hence I ended up using a promise and awaiting its resolution which seemed to work out just fine.
// server.js

const http = require('http');
const url = require('url');
const weather = require('./weather.js')
const fs = require('fs')

fs.readFile('./config.json', (err, config) => {
    if (err || !config) return console.log('Missing Config, see README')
    config = JSON.parse(config)
    if(!config.api_key) return console.log('Missing API Key, see README')
    return run(config.api_key, config.port)
})

const description_map = {
    'Snow': 'Snowy',
    'Mist': 'Fog',
    'Clouds': 'Cloudy',
}

function run(api_key, port = 9999)
{
    const server = http.createServer()

    server.on('request', async (req, res)=>  {
        const query = url.parse(req.url, true).query;

        let reply

        if (query.q) {

            try {
                let result = await weather.get_current(api_key, query.q)

                if(result.success){
                    let temp_c = Math.round(result.success.main.temp)
                    let temp_f = Math.round((temp_c * 1.8) + 32)
                    let description

                    if (result.success.weather[0]){
                        if(result.success.weather[0].main in description_map){
                            description = description_map[result.success.weather[0].main]
                        } else {
                            description = result.success.weather[0].main
                        }
                    }

                    reply = `<?xml version="1.0" encoding="UTF-8"?><data><current_condition><temp_C>${temp_c}</temp_C><temp_F>${temp_f}</temp_F>
                        <weatherDesc><![CDATA[${description}]]></weatherDesc></current_condition></data>`.replace(/\s/g, '')
                } else {
                    console.log('Bad result from weather call:', result)
                }

            } catch (err) {
                console.log('Error from weather call:', err)
            }

            if (reply) {
                res.writeHead(200, {
                    'Content-Type': 'text/xml',
                });
                res.write(reply);
            } else {
                res.writeHead(500, {
                    'Content-Type': 'text/xml'
                });
            }

            res.end();
        }

    }).listen(port);
}
// weather.js

let request = require('request');

var res = function(data, success = true)
{
    return { [success ? 'success' : 'error'] : data }
}

module.exports = {
    get_current: function(apiKey, city)
    {
        return new Promise((resolve, reject) => {

            if(!apiKey) reject(res('Missing API Key', false))
            if(!city) reject(res('Missing City', false))

            let url = `http://api.openweathermap.org/data/2.5/weather?q=${city}&units=metric&appid=${apiKey}`

            request(url, function (err, response, body) {
                if (err) reject(res(err, false))
                let weather = JSON.parse(body)
                if (weather.cod == 200) resolve(res(weather))
                reject(res(weather.message, false))
            });
        })
    }
}


In order to redirect the current weather requests to the new server, the simplest, albeit a bit hacky, Windows solution I could come up with was to add an entry to the hosts file redirecting traffic to www.worldweatheronline.com to a new local interface (127.0.0.2): Host file entry:

127.0.0.2 www.worldweatheronline.com

Thereafter I could use netsh to redirect traffic on 127.0.0.2 to my node server which is running on localhost:

netsh interface portproxy add v4tov4 listenport=80 listenaddress=127.0.0.2 connectport=9999 connectaddress=127.0.0.1

Debugging

I thought this was an alright solution, the only issue was that it didn't seem to work. This required some head scratching. Initially I suspected that I'd missed some required headers in the response so I set about comparing the real www.worldweatheronline.com response to mine and also to the mock response I had made in Fiddler. I made sure everything was the same, alas no dice. But there was one subtle difference, the values for the temperatures! In my mock data the temp values were all integers but in the data from the openweathermap.org API we were getting figures with 2DP of precission. I wouldn't have imagined something like that would break it but it did. So after fettling the formatting of the temp values we were golden. Almost. The next issue was the weather icons... I initially assumed that these were the icons be supplied by the API, but they weren't. Removing the weather icon section from the response:

<weatherIconUrl>
 <![CDATA[http://cdn.worldweatheronline.net/images/wsymbols01_png_64/wsymbol_0002_sunny_intervals.png]]>
</weatherIconUrl>

made no difference. On further inspection the weather symbols from the API don't even match those on the device. Therefore I deduced that the keyboard has it's own icons. After some more testing I worked out that the icons change based on the presence of certain keywords in the weatherDesc tag: e.g.

<weatherDesc>
 <![CDATA[Partly cloudy]]>
</weatherDesc>

In this example the keyword 'cloudy' is all that's needed to display the cloudy icon.

Luckily most of the sought keywords have matching equivalents in the openweathermap.org API! So not much work was needed there bar a couple of mappings:

const description_map = {
 'Mist': 'Fog',
 'Clouds': 'Cloudy',
}

You may have heard Kate Bush sing 50 words for snow but I couldn't find one that would make the keyboard show a snowing icon. I spent a lot of time going through historical Scottish weather data in the www.worldweatheronline.com API to see what possible values it could be (note these don't seem to be explicitly listed anywhere) and found many candidates including the likes of: 'Light snow showers' and 'Blizzard' but none seemed to work. And so I moved on to de-compiling the app in a hunt for snow. I used dotPeek by JetBrains, which was very useful. Unfortunately I couldn't find what I was looking for. So I've just left that for now and will probably only revisit it again when it next starts snowing.

The weather works great now (until it snows...). To polish things off I used an npm package called qckwinsvc to create a windows service so I could have the server start up automatically. If you've got one of these keyboards and would like to get the weather applet working again the installation instructions are in the repo here: GavG/smartkey.

As an experiment I did have a go at bundling the app into an executable (.exe) using a library called pkg however I struggled to get it to include node in the output (this would have been wastefully large anyway). Plus if I were to make it an executable I'd expect it to be able to update the hosts file, add the netsh entry and register the service all by itself. This isn't something I really fancied doing as I'd had enough of Windows by this point. If someone fancies doing something like this however I'd be very interested to see the solution.

Smartype keyboard working weather app