├── .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 | 
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 | 
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 | 
131 |
132 | 使用单色的BMP输出(可直接加载到墨水屏):
133 |
134 | `http://树莓派的IP:部署端口/calendar?width=400&height=300&bit=1`
135 |
136 | 
137 |
138 | 隐藏温度:
139 |
140 | `http://树莓派的IP:部署端口/calendar?width=400&height=300&hideTemp=1`
141 |
142 | 
143 |
144 | 隐藏天气:
145 |
146 | `http://树莓派的IP:部署端口/calendar?width=400&height=300&hideWeather=1`
147 |
148 | 
149 |
150 | 隐藏天气和温度:
151 |
152 | `http://树莓派的IP:部署端口/calendar?width=400&height=300&hideWeather=1&hideTemp=1`
153 |
154 | 
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 |
--------------------------------------------------------------------------------