├── .gitignore ├── LICENSE ├── README.md ├── demo ├── 400300.bmp ├── 400300.jpeg ├── default.jpeg ├── hideTemp.jpeg ├── hideWeather.jpeg ├── hideWeatherAndTemp.jpeg ├── pic_1.jpg └── pic_2.jpg ├── index.html ├── index.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # vscode 64 | .vscode 65 | 66 | # data 67 | data 68 | 69 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 lxrmido 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-paper-calendar 2 | 3 | 这是基于NodeJS的提供黑白色天气日历图片服务的程序,用于制作树莓派墨水屏日历,也可以在oled屏幕、lcd屏幕或者其他任何屏幕上使用,理论上兼容任何分辨率(看不看得清楚是另一回事) 4 | 5 | ![demo](./demo/pic_1.jpg) 6 | 7 | ``` 8 | # 2019年11月13日: 9 | # 近期和风天气SSL证书有点问题,获取天气信息会出错,可在.env中添加以下配置项临时解决: 10 | NODE_TLS_REJECT_UNAUTHORIZED=0 11 | ``` 12 | 13 | ``` 14 | # 2019年12月18日: 15 | # 最近墨水屏摔坏了,考虑用吃灰的kindle的浏览器去替代前端显示的部分,于是添加了一个首页,访问即可自适应屏幕最大化显示日历,并对竖屏设备自动横屏处理,兼容以往的URL参数 16 | ``` 17 | 18 | ## 基本说明 19 | 20 | 做这个项目之时,我想做的是一个墨水屏展示当前天气、24小时温度曲线及日历的小玩具。市面上可以买到的、提供开发资料墨水屏不外乎大连的、微雪的(其实也是大连的),无论哪一种,作为一个电路苦手,我都不想深入了解其驱动和底层实现,我的思路是: 21 | 22 | 1. 给树莓派接一个 DS18B20 或者别的基于 1-wired 的温度传感器,因为这类传感器只用接一根GPIO线、并且只需要读取文件就能获取数据,不需要额外写底层代码; 23 | 24 | 2. 做一个基于 NodeJS 的程序,用于记录温度变化信息、获取天气预报数据,然后生成一张黑白的图片,开启一个 WEB 服务器提供这张图片; 25 | 26 | 3. 稍微修改一下墨水屏提供的示例程序,让它定时从上一步写的服务中获取图片,然后直接在墨水屏上显示图片,这样一来,无论墨水屏提供的示例代码是基于什么语言的,都不难实现; 27 | 28 | ## 怎样部署 29 | 30 | 我设计了两种部署方案: 31 | 32 | 1. 在树莓派上部署当前项目; 33 | 34 | 2. 在树莓派上部署一个上报温度数据的服务,然后在另一台服务器上部署当前项目; 35 | 36 | 程序运行时,会检查当前机器(不论是不是树莓派)上有没有`DS18B20`传感器,如果有的话会每十秒钟读取一次温度,如果没有会继续运行剩下的服务,包括等待其他设备向它上报温度信息; 37 | 38 | 如果要使用天气预报的服务,你需要去`和风天气`中注册一个账号,获取一个key写入配置文件`.env`中 39 | 40 | `.env`的配置项如下: 41 | ``` 42 | # 服务监听端口 43 | SERIVCE_PORT=3000 44 | # 天气预报的城市或者地区 45 | WEATHER_LOCATION=guangzhou 46 | # 和风天气的key 47 | WEATHER_KEY= 48 | ``` 49 | 50 | 如果你选择在一个树莓派中完成所有事情,那么你可以跳过下面的内容,直接去下一章节。 51 | 52 | 如果你选择把上报天气的程序部署在别的树莓派上,那么你可以在接入了 DS18B20 的树莓派上部署我的另一个项目: 53 | 54 | [rasp-w1-temp](https://github.com/lxrmido/rasp-w1-temp/) 55 | 56 | 然后在它的`.env`中配置: 57 | 58 | ``` 59 | REPORT_URL=http://当前服务地址:当前服务端口/set 60 | ``` 61 | 62 | 如果你选择自己写一个上报温度的程序,或者用其他的、ESP32之类的设备去代替树莓派,那么你需要做的是写一个程序或者SHELL脚本定时向上述配置地址发起一个`POST`请求,在里边传递一个如下的`JSON`数据: 63 | 64 | ``` 65 | {"temp": 27000} 66 | ``` 67 | 68 | 表示上报一个27.000度的数据,当然,`temp`这个键值是可配置的,只需要在`.env`中添加: 69 | 70 | ``` 71 | TEMP_KEY=foobar 72 | ``` 73 | 74 | 甚至,你可以在获取图片的时候才传递这个参数,程序会同时记录多个不同值的变化信息,来实现同一个程序记录多个设备上报的信息,程序默认会记录最近的`8640`个数据,`8640`这个值的配置项为: 75 | 76 | ``` 77 | CHANGES_LIMIT=8640 78 | ``` 79 | 80 | ## DS18B20 的安装方法 81 | 82 | DS18B20只有三根线,一根连接`3.3v的VCC`,一根连接`GND`,剩下的数据线连接一个`GPIO`口,默认是`GPIO.7`,也就是树莓派左边一排`GPIO`插针从上往下数的第4个; 83 | 84 | 执行以下的命令启用单总线协议: 85 | 86 | ``` 87 | sudo modprobe w1-gpio 88 | sudo modprobe w1-therm 89 | ``` 90 | 91 | 编辑`/boot/config.txt`,在最下面添加一行: 92 | 93 | ``` 94 | dtoverlay=w1-gpio 95 | ``` 96 | 97 | 然后重启树莓派,你能在`/sys/bus/w1/devices/`中看到它; 98 | 99 | ## 本程序的安装方法 100 | 101 | 本程序用到了[node-canvas](https://www.npmjs.com/package/canvas),因此建议你先运行下面的命令去装一些库: 102 | 103 | ``` 104 | sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev 105 | ``` 106 | 107 | 然后 clone 本项目,执行 `npm install` 或者 `yarn` 去安装依赖的 `node` 包 108 | 109 | 然后 `node index` 运行此程序 110 | 111 | 然后你就能在浏览器上打开 `http://树莓派的IP:部署端口/calendar` 看到一个日历: 112 | 113 | ![demo](./demo/default.jpeg) 114 | 115 | 这个接口接受以下参数: 116 | 117 | |参数|说明|示例|默认值| 118 | |---|---|---|---| 119 | |width|图片宽度|400|640| 120 | |height|图片高度|300|384| 121 | |bit|是否显示为单色BMP|1|0| 122 | |hideWeather|是否隐藏天气信息|1|0| 123 | |hideTemp|是否隐藏温度曲线|1|0| 124 | |tempKey|温度曲线使用的键值|foobar|temp| 125 | 126 | 譬如把尺寸改为`400x300`: 127 | 128 | `http://树莓派的IP:部署端口/calendar?width=400&height=300` 129 | 130 | ![demo](./demo/400300.jpeg) 131 | 132 | 使用单色的BMP输出(可直接加载到墨水屏): 133 | 134 | `http://树莓派的IP:部署端口/calendar?width=400&height=300&bit=1` 135 | 136 | ![demo](./demo/400300.bmp) 137 | 138 | 隐藏温度: 139 | 140 | `http://树莓派的IP:部署端口/calendar?width=400&height=300&hideTemp=1` 141 | 142 | ![demo](./demo/hideTemp.jpeg) 143 | 144 | 隐藏天气: 145 | 146 | `http://树莓派的IP:部署端口/calendar?width=400&height=300&hideWeather=1` 147 | 148 | ![demo](./demo/hideWeather.jpeg) 149 | 150 | 隐藏天气和温度: 151 | 152 | `http://树莓派的IP:部署端口/calendar?width=400&height=300&hideWeather=1&hideTemp=1` 153 | 154 | ![demo](./demo/hideWeatherAndTemp.jpeg) 155 | 156 | 157 | ## 关于数据 158 | 159 | 程序获取到的数据默认备份在`data`目录下,每次程序运行时将读取到内存中,可通过配置项`DATA_DIR`进行修改,默认每分钟备份一次,备份间隔可通过配置项`BACKUP_INTERVAL`修改 160 | 161 | ## 其他的一些接口和说明 162 | 163 | 除了`/calendar`外,这个程序附带了一些其他接口 164 | 165 | ### 更新若干个键值 166 | 167 | 接口:`/set` 168 | 169 | 方法:`POST` 170 | 171 | 数据格式:`json` 172 | 173 | 参数示例: 174 | 175 | ```json 176 | { 177 | "foo": 123, 178 | "bar": "456" 179 | } 180 | ``` 181 | 182 | ### 获取某个键的最新值 183 | 184 | 接口:`/get/{key}` 185 | 186 | 方法:`GET` 187 | 188 | 调用示例:`/get/foo` 189 | 190 | 返回值示例: 191 | 192 | ```json 193 | { 194 | "value": 123, 195 | "updated": 12345678901 196 | } 197 | ``` 198 | 199 | ### 获取某个键最近8640个值 200 | 201 | 接口:`/changes/{key}` 202 | 203 | 方法:`GET` 204 | 205 | 返回值示例: 206 | 207 | ```json 208 | { 209 | "changes": [ 210 | { 211 | "value": 123, 212 | "updated": 12345678901 213 | }, 214 | { 215 | "value": 122, 216 | "updated": 12345678900 217 | } 218 | ] 219 | } 220 | ``` 221 | 222 | ## 获取某个值今天的所有值: 223 | 224 | 接口:`/today/{key}` 225 | 226 | 方法:`GET` 227 | 228 | 返回值示例: 229 | 230 | ```json 231 | { 232 | "changes": [ 233 | { 234 | "value": 123, 235 | "updated": 12345678901 236 | }, 237 | { 238 | "value": 122, 239 | "updated": 12345678900 240 | } 241 | ] 242 | } 243 | ``` 244 | -------------------------------------------------------------------------------- /demo/400300.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxrmido/node-paper-calendar/8fc39b0a1b195b788fe27de9499940766810eda5/demo/400300.bmp -------------------------------------------------------------------------------- /demo/400300.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxrmido/node-paper-calendar/8fc39b0a1b195b788fe27de9499940766810eda5/demo/400300.jpeg -------------------------------------------------------------------------------- /demo/default.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxrmido/node-paper-calendar/8fc39b0a1b195b788fe27de9499940766810eda5/demo/default.jpeg -------------------------------------------------------------------------------- /demo/hideTemp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxrmido/node-paper-calendar/8fc39b0a1b195b788fe27de9499940766810eda5/demo/hideTemp.jpeg -------------------------------------------------------------------------------- /demo/hideWeather.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxrmido/node-paper-calendar/8fc39b0a1b195b788fe27de9499940766810eda5/demo/hideWeather.jpeg -------------------------------------------------------------------------------- /demo/hideWeatherAndTemp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxrmido/node-paper-calendar/8fc39b0a1b195b788fe27de9499940766810eda5/demo/hideWeatherAndTemp.jpeg -------------------------------------------------------------------------------- /demo/pic_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxrmido/node-paper-calendar/8fc39b0a1b195b788fe27de9499940766810eda5/demo/pic_1.jpg -------------------------------------------------------------------------------- /demo/pic_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxrmido/node-paper-calendar/8fc39b0a1b195b788fe27de9499940766810eda5/demo/pic_2.jpg -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Calendar 8 | 34 | 35 | 36 |
37 | 38 | 103 | 104 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | var express = require('express'); 3 | var bodyParser = require("body-parser"); 4 | var fs = require('fs'); 5 | var app = express(); 6 | var canvas = require('canvas'); 7 | var solarLunar = require('solarlunar'); 8 | var request = require('request') 9 | var bmp = require('fast-bmp'); 10 | var path = require('path'); 11 | 12 | app.use(bodyParser.json()); 13 | app.use(bodyParser.urlencoded({ extended: true })); 14 | 15 | let w1DeviceFile = null; 16 | let w1DeviceDir = "/sys/bus/w1/devices/"; 17 | 18 | if (process.env.W1_DEVICE) { 19 | w1DeviceFile = process.env.W1_DEVICE; 20 | } else { 21 | if (fs.existsSync(w1DeviceDir)) { 22 | fs.readdirSync(w1DeviceDir).forEach(function (name) { 23 | if (name.indexOf('28-') === 0) { 24 | w1DeviceFile = w1DeviceDir + name + '/w1_slave'; 25 | } 26 | }); 27 | } 28 | } 29 | 30 | if (!w1DeviceFile) { 31 | console.log('No 1-wired device found, local report disabled.'); 32 | } 33 | 34 | var config = { 35 | servicePort: process.env.SERIVCE_PORT || 3000, 36 | dataDir: process.env.DATA_DIR || 'data', 37 | backupValuesFile: process.env.BACKUP_VALUES_FILE || 'data/values.json', 38 | backupChangesFile: process.env.BACKUP_CHANGES_FILE || 'data/changes.json', 39 | backupInterval: process.env.BACKUP_INTERVAL || 60000, 40 | changesLimit: process.env.CHANGES_LIMIT || 8640, 41 | tempKey: process.env.TEMP_KEY || 'temp', 42 | weatherLocation: process.env.WEATHER_LOCATION, 43 | weatherKey: process.env.WEATHER_KEY 44 | }; 45 | 46 | var valuesMap = { 47 | 48 | }; 49 | 50 | var changesMap = { 51 | 52 | }; 53 | 54 | var daily = { 55 | dir: null, 56 | key: null, 57 | changes: { 58 | 59 | } 60 | }; 61 | 62 | var weatherData = []; 63 | 64 | if (!fs.existsSync(config.dataDir)) { 65 | fs.mkdirSync(config.dataDir); 66 | } 67 | 68 | if (fs.existsSync(config.backupValuesFile)) { 69 | valuesMap = JSON.parse(fs.readFileSync(config.backupValuesFile)); 70 | } 71 | 72 | if (fs.existsSync(config.backupChangesFile)) { 73 | changesMap = JSON.parse(fs.readFileSync(config.backupChangesFile)); 74 | } 75 | 76 | initRotate(); 77 | 78 | app.get('/', function (req, res) { 79 | res.sendFile(path.join(__dirname + '/index.html')); 80 | }); 81 | 82 | app.post('/set', function (req, res) { 83 | for (let i in req.body) { 84 | valuesMap[i] = { 85 | value: req.body[i], 86 | updated: (new Date()).getTime() 87 | }; 88 | process.nextTick(function () { 89 | addChange(i); 90 | }); 91 | } 92 | res.send({ 93 | result: 'success' 94 | }); 95 | }); 96 | 97 | app.get('/get/:key', function (req, res) { 98 | let key = req.params.key; 99 | if (key in valuesMap) { 100 | res.send(valuesMap[key]); 101 | } else { 102 | res.send({ 103 | value: null 104 | }); 105 | } 106 | }); 107 | 108 | app.get('/today/:key', function (req, res) { 109 | let key = req.params.key; 110 | if (key in daily.changes) { 111 | res.send({ 112 | changes: daily.changes[key] 113 | }); 114 | } else { 115 | res.send({ 116 | changes: [] 117 | }); 118 | } 119 | }); 120 | 121 | app.get('/changes/:key', function (req, res) { 122 | let key = req.params.key; 123 | if (key in changesMap) { 124 | res.send({ 125 | changes: changesMap[key] 126 | }); 127 | } else { 128 | res.send({ 129 | changes: [] 130 | }); 131 | } 132 | }); 133 | 134 | app.get('/calendar', function (req, res) { 135 | let width = 640; 136 | let height = 384; 137 | let hideWeather = req.query.hideWeather && parseInt(req.query.hideWeather) > 0; 138 | let hideTemp = req.query.hideTemp && parseInt(req.query.hideTemp) > 0; 139 | let bit = req.query.bit && parseInt(req.query.bit) > 0; 140 | if (req.query.width && req.query.width > 0) { 141 | width = parseInt(req.query.width); 142 | } 143 | if (req.query.height && req.query.height > 0) { 144 | height = parseInt(req.query.height); 145 | } 146 | let tempKey = config.tempKey; 147 | if (req.query.tempKey) { 148 | tempKey = req.query.tempKey; 149 | } 150 | 151 | let cvs = canvas.createCanvas(width, height); 152 | let ctx = cvs.getContext('2d'); 153 | 154 | ctx.fillStyle = '#ffffff'; 155 | ctx.fillRect(0, 0, width, height); 156 | 157 | let calendarWidth = Math.floor(width / 3 * 2); 158 | let calendarHeight = Math.floor(height / 4 * 3); 159 | let calendarX = Math.floor(width / 3); 160 | let calendarY = 0; 161 | 162 | let tempWidth = width; 163 | let tempHeight = Math.floor(height / 4); 164 | let tempX = 0; 165 | let tempY = Math.floor(height / 4 * 3); 166 | 167 | let weatherWidth = Math.floor(width / 3); 168 | let weatherHeight = Math.floor(height / 4 * 3); 169 | let weatherX = 0; 170 | let weatherY = 0; 171 | 172 | 173 | if (hideTemp) { 174 | calendarHeight = height; 175 | weatherHeight = height; 176 | } 177 | if (hideWeather) { 178 | calendarWidth = width; 179 | calendarX = 0; 180 | } 181 | 182 | let cvsCalendar = drawCalendar(calendarWidth, calendarHeight); 183 | ctx.drawImage(cvsCalendar, calendarX, calendarY); 184 | 185 | if (!hideTemp) { 186 | let cvsTemps = drawChanges(tempWidth, tempHeight, tempKey, function (cur, min, max) { 187 | return '温度:' + (cur / 1000).toFixed(1) + '℃,过去24小时:' + (min / 1000).toFixed(1) + ' - ' + (max / 1000).toFixed(1) + '℃'; 188 | }); 189 | ctx.drawImage(cvsTemps, tempX, tempY); 190 | } 191 | 192 | if (!hideWeather) { 193 | let cvsForecast = drawWeatherForecast(weatherWidth, weatherHeight); 194 | ctx.drawImage(cvsForecast, weatherX, weatherY); 195 | } 196 | 197 | var mime, img; 198 | 199 | if (bit) { 200 | mime = 'image/bmp', 201 | img = canvasToBitmap(cvs); 202 | } else { 203 | mime = 'image/jpeg' 204 | img = cvs.toBuffer('image/jpeg', {quality: 1}); 205 | } 206 | 207 | res.writeHead(200, { 208 | 'Content-Type': mime, 209 | 'Content-Length': img.length 210 | }); 211 | res.end(img); 212 | }); 213 | 214 | function drawCalendar(width, height){ 215 | let cvs = canvas.createCanvas(width, height); 216 | let ctx = cvs.getContext('2d'); 217 | ctx.fillStyle = '#ffffff'; 218 | ctx.fillRect(0, 0, width, height); 219 | ctx.strokeStyle = '#000000'; 220 | ctx.fillStyle = '#000000'; 221 | 222 | initContext2d(ctx); 223 | 224 | ctx.textAlign = 'center'; 225 | ctx.textBaseline = 'middle'; 226 | let date = new Date(); 227 | let dayX = 0; 228 | let dayY = 0; 229 | let dayHeight = Math.floor(height / 4 * 3); 230 | let dayWidth = Math.floor(width); 231 | let dayText = date.getDate(); 232 | let dayFont = ctx.getPropertySingleLineFont(dayText, 400, 20, null, dayWidth - 16, dayHeight - 16); 233 | ctx.font = dayFont.font; 234 | ctx.fillText(dayText, Math.floor(dayX + dayWidth / 2), Math.floor(dayY + dayHeight / 2) + dayFont.offsetY); 235 | 236 | let lunarX = 0; 237 | let lunarY = Math.floor(height / 4 * 3); 238 | let lunarWidth = Math.floor((width - lunarX) * 2 / 3); 239 | let lunarHeight = Math.floor(height / 4); 240 | let lunarInfo = solarLunar.solar2lunar(date.getFullYear(), date.getMonth() + 1, date.getDate()); 241 | let lunarText = lunarInfo.monthCn + lunarInfo.dayCn; 242 | let lunarFont = ctx.getPropertySingleLineFont(lunarText, 100, null, null, lunarWidth - 16, lunarHeight - 16); 243 | ctx.font = lunarFont.font; 244 | ctx.fillText(lunarText, Math.floor(lunarX + lunarWidth / 2), Math.floor(lunarY + lunarHeight / 2 + lunarFont.offsetY)); 245 | 246 | let monthX = lunarX + lunarWidth; 247 | let monthY = lunarY; 248 | let monthWidth = Math.floor(lunarWidth / 2); 249 | let monthHeight = Math.floor(lunarHeight / 2); 250 | let monthText = date.getFullYear() + '年' + (date.getMonth() + 1) + '月'; 251 | let monthFont = ctx.getPropertySingleLineFont(monthText, 100, null, null, monthWidth, monthHeight); 252 | ctx.font = monthFont.font; 253 | ctx.fillText(monthText, Math.floor(monthX + monthWidth / 2), Math.floor(monthY + monthHeight / 2 + monthFont.offsetY)); 254 | 255 | let weekX = lunarX + lunarWidth; 256 | let weekY = lunarY + monthHeight; 257 | let weekWidth = Math.floor(lunarWidth / 2); 258 | let weekHeight = Math.floor(lunarHeight / 2); 259 | let weekText = lunarInfo.ncWeek; 260 | let weekFont = ctx.getPropertySingleLineFont(weekText, 100, null, null, weekWidth, weekHeight); 261 | ctx.font = weekFont.font; 262 | ctx.fillRect(weekX, weekY, weekWidth, weekHeight); 263 | ctx.fillStyle = '#ffffff'; 264 | ctx.fillText(weekText, Math.floor(weekX + weekWidth / 2), Math.floor(weekY + weekHeight / 2 + weekFont.offsetY)); 265 | 266 | return cvs; 267 | } 268 | 269 | function drawChanges(width, height, key, showText){ 270 | let datas = []; 271 | if (key in changesMap) { 272 | datas = changesMap[key]; 273 | } 274 | let cvs = canvas.createCanvas(width, height); 275 | let ctx = cvs.getContext('2d'); 276 | let calcValues = []; 277 | let pixWidth = 1, pixHeight = 1; 278 | 279 | ctx.fillStyle = '#ffffff'; 280 | ctx.fillRect(0, 0, width, height); 281 | 282 | ctx.strokeStyle = '#000000'; 283 | ctx.beginPath(); 284 | 285 | initContext2d(ctx); 286 | 287 | let numberDatas = []; 288 | 289 | for (let i = 0; i < datas.length; i ++) { 290 | if (isNaN(datas[i].value)) { 291 | continue; 292 | } 293 | numberDatas.push(parseFloat(datas[i].value)); 294 | } 295 | 296 | if (numberDatas.length <= 1) { 297 | ctx.moveTo(0, Math.floor(height / 2)); 298 | ctx.lineTo(width - 1, Math.floor(height / 2)); 299 | } else { 300 | if (width > numberDatas.length) { 301 | pixWidth = Math.floor(width / numberDatas.length); 302 | calcValues = numberDatas; 303 | } else { 304 | let valsPerPix = Math.floor(numberDatas.length / width); 305 | let cx = 0; 306 | while (cx < width) { 307 | let subGroup = numberDatas.slice(cx * valsPerPix, cx * valsPerPix + valsPerPix); 308 | if (subGroup.length > 0) { 309 | calcValues.push(Math.round(subGroup.reduce((a, b) => a + b) / subGroup.length)); 310 | } 311 | cx ++; 312 | } 313 | } 314 | let minValue = Math.min(...calcValues); 315 | let maxValue = Math.max(...calcValues); 316 | 317 | if (minValue == maxValue) { 318 | ctx.moveTo(0, Math.floor(height / 2)); 319 | ctx.lineTo(width - 1, Math.floor(height / 2)); 320 | } else { 321 | let scaleY = height / (maxValue - minValue); 322 | for (let i = 0; i < calcValues.length; i ++) { 323 | ctx.lineTo(pixWidth * i, Math.floor(scaleY * (maxValue - calcValues[i]))); 324 | } 325 | } 326 | ctx.stroke(); 327 | 328 | if (showText) { 329 | let text = showText(numberDatas[numberDatas.length - 1], Math.min(...numberDatas), Math.max(...numberDatas)) 330 | let textFont = ctx.getPropertySingleLineFont(text, null, null, null, width - 8, height / 4); 331 | ctx.font = textFont.font; 332 | ctx.textBaseline = 'bottom'; 333 | ctx.fillStyle = '#ffffff'; 334 | ctx.fillText(text, 6, height - 6); 335 | ctx.fillText(text, 2, height - 2); 336 | ctx.fillStyle = '#000000'; 337 | ctx.fillText(text, 4, height - 4); 338 | } 339 | } 340 | return cvs; 341 | } 342 | 343 | function drawWeatherForecast(width, height){ 344 | 345 | let cvs = canvas.createCanvas(width, height); 346 | let ctx = cvs.getContext('2d'); 347 | 348 | initContext2d(ctx); 349 | 350 | ctx.fillStyle = '#ffffff'; 351 | ctx.fillRect(0, 0, width, height); 352 | 353 | ctx.beginPath(); 354 | ctx.moveTo(0, Math.floor(height / 3)); 355 | ctx.lineTo(width - 1, Math.floor(height / 3)); 356 | ctx.moveTo(0, Math.floor(height / 3 * 2)); 357 | ctx.lineTo(width - 1, Math.floor(height / 3 * 2)); 358 | ctx.stroke(); 359 | 360 | ctx.fillStyle = '#000000'; 361 | let rowHeight = Math.floor(height / 3); 362 | let labelHeight = Math.floor(rowHeight / 3); 363 | let labelWidth = labelHeight * 2; 364 | 365 | ctx.textAlign = 'center'; 366 | ctx.textBaseline = 'middle'; 367 | 368 | let labelFont = ctx.getPropertySingleLineFont('今天', null, null, null, labelWidth, labelHeight); 369 | 370 | ctx.fillRect(0, 0, labelWidth, labelHeight); 371 | ctx.fillRect(0, rowHeight, labelWidth, labelHeight); 372 | ctx.fillRect(0, rowHeight * 2, labelWidth, labelHeight); 373 | 374 | ctx.fillStyle = '#ffffff'; 375 | ctx.font = labelFont.font; 376 | ctx.fillText('今天', labelWidth / 2, labelHeight / 2 + labelFont.offsetY); 377 | ctx.fillText('明天', labelWidth / 2, labelHeight / 2 + labelFont.offsetY + rowHeight); 378 | ctx.fillText('后天', labelWidth / 2, labelHeight / 2 + labelFont.offsetY + rowHeight * 2); 379 | 380 | ctx.fillStyle = '#000000'; 381 | 382 | if (weatherData.length) { 383 | weatherData.forEach(function (day, index) { 384 | let tmpText = day.tmp_min + ' ~ ' + day.tmp_max + '℃'; 385 | let tmpX = labelWidth; 386 | let tmpY = index * rowHeight; 387 | let tmpWidth = width - tmpX; 388 | let tmpHeight = labelHeight; 389 | let tmpFont = ctx.getPropertySingleLineFont(tmpText, null, null, null, tmpWidth - 8, tmpHeight - 8); 390 | ctx.font = tmpFont.font; 391 | ctx.fillText(tmpText, tmpX + tmpWidth / 2, tmpY + tmpHeight / 2 + tmpFont.offsetY); 392 | 393 | let condText = day.cond_txt_d + ' / ' + day.cond_txt_n; 394 | let condX = 0; 395 | let condY = index * rowHeight + tmpHeight; 396 | let condWidth = width; 397 | let condHeight = labelHeight; 398 | let condFont = ctx.getPropertySingleLineFont(condText, null, null, null, condWidth - 8, condHeight - 8); 399 | ctx.font = condFont.font; 400 | ctx.fillText(condText, condX + condWidth / 2, condY + condHeight / 2 + condFont.offsetY); 401 | 402 | let sunText = '日出 ' + day.sr + ' 日落 ' + day.ss; 403 | let sunX = 0; 404 | let sunY = index * rowHeight + tmpHeight + condHeight; 405 | let sunWidth = width; 406 | let sunHeight = labelHeight; 407 | let sunFont = ctx.getPropertySingleLineFont(sunText, null, null, null, sunWidth - 8, sunHeight - 8); 408 | ctx.font = sunFont.font; 409 | ctx.fillText(sunText, sunX + sunWidth / 2, sunY + sunHeight / 2 + sunFont.offsetY); 410 | }); 411 | } 412 | 413 | return cvs; 414 | 415 | } 416 | 417 | app.listen(config.servicePort, function () { 418 | console.log('Listening on port ' + config.servicePort); 419 | }); 420 | 421 | function getWeatherForecastData(){ 422 | if (!config.tempKey) { 423 | console.log('No Weather Key'); 424 | return; 425 | } 426 | let url = 'https://free-api.heweather.net/s6/weather/forecast?location=' + config.weatherLocation + '&key=' + config.weatherKey; 427 | 428 | request.get(url, function (err, data) { 429 | console.log(new Date().toString()) 430 | if (err) { 431 | console.log('Get Weather Data Failed:' + err); 432 | setTimeout(getWeatherForecastData, 60000); 433 | return; 434 | } 435 | try { 436 | let fcData = JSON.parse(data.body); 437 | if (fcData.HeWeather6 && fcData.HeWeather6.length && fcData.HeWeather6[0].daily_forecast) { 438 | weatherData = fcData.HeWeather6[0].daily_forecast; 439 | console.log('Weather data refreshed.'); 440 | } else { 441 | console.log('Weather data format unexpected.'); 442 | console.log(data.body); 443 | } 444 | } catch(e) { 445 | console.log('Weather data parsing error.'); 446 | console.log(data.body); 447 | } 448 | setTimeout(getWeatherForecastData, 900000); 449 | }) 450 | } 451 | 452 | function initContext2d(ctx){ 453 | ctx.getPropertySingleLineFont = function (text, max, min, font, width, height) { 454 | font = font || 'Impact' 455 | max = max || 40; 456 | min = min || 6; 457 | var lastFont = this.font; 458 | var fontSize = max; 459 | this.font = fontSize + 'px ' + font; 460 | var metrics = this.measureText(text); 461 | while (metrics.width > width || metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent > height) { 462 | fontSize -= 1; 463 | if (fontSize < min) { 464 | this.font = lastFont; 465 | return { 466 | font: min + 'px ' + font, 467 | offsetY: Math.floor((metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2), 468 | width: metrics.width 469 | }; 470 | } 471 | this.font = fontSize + 'px ' + font; 472 | metrics = this.measureText(text); 473 | } 474 | this.font = lastFont; 475 | return { 476 | font: fontSize + 'px ' + font, 477 | offsetY: Math.floor((metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2), 478 | width: metrics.width 479 | }; 480 | } 481 | } 482 | 483 | function addChange(key){ 484 | if (!(key in changesMap)) { 485 | changesMap[key] = []; 486 | } else { 487 | if (changesMap[key].length >= config.changesLimit) { 488 | changesMap[key].splice(0, changesMap[key].length - config.changesLimit + 1); 489 | } 490 | } 491 | changesMap[key].push(valuesMap[key]); 492 | checkRotate(); 493 | if (!(key in daily.changes)) { 494 | daily.changes[key] = []; 495 | } 496 | daily.changes[key].push(valuesMap[key]); 497 | } 498 | 499 | function getDailyKey() { 500 | let d = new Date(); 501 | function pad(x) { 502 | return (x > 9 ? '' : '0') + x; 503 | } 504 | return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); 505 | } 506 | 507 | function checkRotate(){ 508 | let curKey = getDailyKey(); 509 | if (curKey != daily.key) { 510 | backupDaily(); 511 | daily.dir = config.dataDir + '/' + curKey; 512 | daily.key = curKey; 513 | daily.changes = {}; 514 | loadRotateIfExists(); 515 | } 516 | } 517 | 518 | function initRotate(){ 519 | let curKey = getDailyKey(); 520 | daily.dir = config.dataDir + '/' + curKey + '/'; 521 | daily.key = curKey; 522 | loadRotateIfExists(); 523 | } 524 | 525 | function loadRotateIfExists(){ 526 | if (!fs.existsSync(daily.dir)) { 527 | fs.mkdirSync(daily.dir); 528 | return false; 529 | } 530 | fs.readdirSync(daily.dir).forEach(function (name) { 531 | let segs = name.split('.'); 532 | daily.changes[segs[0]] = JSON.parse(fs.readFileSync(daily.dir + name)); 533 | }); 534 | return true; 535 | } 536 | 537 | function backupDaily(){ 538 | if (!fs.existsSync(daily.dir)) { 539 | fs.mkdirSync(daily.dir); 540 | } 541 | for (let key in daily.changes) { 542 | fs.writeFileSync(daily.dir + '/' + key + '.json', JSON.stringify(daily.changes[key])); 543 | } 544 | } 545 | 546 | function backupRuntime(){ 547 | fs.writeFileSync(config.backupValuesFile, JSON.stringify(valuesMap)); 548 | fs.writeFileSync(config.backupChangesFile, JSON.stringify(changesMap)); 549 | } 550 | 551 | function quickBackup(){ 552 | fs.writeFile( 553 | config.backupValuesFile, 554 | JSON.stringify(valuesMap), 555 | function (err1) { 556 | if (err1) { 557 | console.log('Backup values failed:' + err1); 558 | } 559 | fs.writeFile( 560 | config.backupChangesFile, 561 | JSON.stringify(changesMap), 562 | function (err2) { 563 | if (err2) { 564 | console.log('Backup values failed:' + err2); 565 | } 566 | setTimeout(quickBackup, config.backupInterval); 567 | } 568 | ) 569 | } 570 | ); 571 | } 572 | 573 | function reportTemp () { 574 | // DS18B20 may lost connect 575 | if(fs.existsSync(w1DeviceFile)){ 576 | let timeStart = new Date().getTime(); 577 | let fileContent = fs.readFileSync(w1DeviceFile).toString(); 578 | let temp = fileContent.match(/t=(\d+)/)[1]; 579 | console.log('Temp reading cost: ' + (new Date().getTime() - timeStart) + 'ms'); 580 | timeStart = new Date().getTime(); 581 | console.log('Temp read at ' + new Date().toString() + ', value: ' + temp); 582 | valuesMap[config.tempKey] = { 583 | value: parseInt(temp), 584 | updated: (new Date()).getTime() 585 | }; 586 | process.nextTick(function () { 587 | addChange(config.tempKey); 588 | }); 589 | }else{ 590 | console.log('Temp read failed at ' + new Date().toString()) 591 | } 592 | setTimeout(reportTemp, 10000); 593 | } 594 | 595 | function canvasToBitmap(cvs){ 596 | let buffer = cvs.toBuffer('raw'); 597 | let offset = 0; 598 | let data = []; 599 | var b1, b2, b3, b4, b5, b6, b7, b8; 600 | while (offset < buffer.length) { 601 | b1 = buffer[offset] + buffer[offset + 1] + buffer[offset + 2] < 510 ? 0b00000000 : 0b10000000; 602 | offset += 4; 603 | b2 = buffer[offset] + buffer[offset + 1] + buffer[offset + 2] < 510 ? 0b00000000 : 0b01000000; 604 | offset += 4; 605 | b3 = buffer[offset] + buffer[offset + 1] + buffer[offset + 2] < 510 ? 0b00000000 : 0b00100000; 606 | offset += 4; 607 | b4 = buffer[offset] + buffer[offset + 1] + buffer[offset + 2] < 510 ? 0b00000000 : 0b00010000; 608 | offset += 4; 609 | b5 = buffer[offset] + buffer[offset + 1] + buffer[offset + 2] < 510 ? 0b00000000 : 0b00001000; 610 | offset += 4; 611 | b6 = buffer[offset] + buffer[offset + 1] + buffer[offset + 2] < 510 ? 0b00000000 : 0b00000100; 612 | offset += 4; 613 | b7 = buffer[offset] + buffer[offset + 1] + buffer[offset + 2] < 510 ? 0b00000000 : 0b00000010; 614 | offset += 4; 615 | b8 = buffer[offset] + buffer[offset + 1] + buffer[offset + 2] < 510 ? 0b00000000 : 0b00000001; 616 | offset += 4; 617 | data.push(b1 | b2 | b3 | b4 | b5 | b6 | b7 | b8); 618 | } 619 | 620 | return bmp.encode({ 621 | width: cvs.width, 622 | height: cvs.height, 623 | data: new Uint8Array(data), 624 | bitDepth: 1, 625 | components: 1, 626 | channels: 1 627 | }) 628 | } 629 | 630 | setTimeout(quickBackup, config.backupInterval); 631 | getWeatherForecastData(); 632 | if (w1DeviceFile) { 633 | reportTemp(); 634 | } 635 | 636 | process.on('SIGINT', (code) => { 637 | backupRuntime(); 638 | backupDaily(); 639 | console.log('Process exit.') 640 | process.exit('SIGINT'); 641 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paper-calendar", 3 | "version": "1.0.0", 4 | "description": "一个提供黑白色天气日历图片服务的node程序,用于制作树莓派墨水屏日历", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/lxrmido/node-paper-calendar.git" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/lxrmido/node-paper-calendar/issues" 17 | }, 18 | "homepage": "https://github.com/lxrmido/node-paper-calendar", 19 | "dependencies": { 20 | "canvas": "^2.6.0", 21 | "dotenv": "^8.0.0", 22 | "express": "^4.17.1", 23 | "fast-bmp": "^1.0.0", 24 | "request": "^2.88.0", 25 | "solarlunar": "^2.0.7" 26 | } 27 | } 28 | --------------------------------------------------------------------------------