树莓派运行墨镜 MagicMirror²

/ 技术文章 / 1 条评论 / 1210浏览

家里的树莓派吃灰了好久了,想着放着不用也是浪费,花个一天时间整个有趣点的项目,网上看到MagicMirror²还蛮有意思的。动手开干!

安装

更新树莓派

好久没有用了,估计操作系统什么的有好多都过时了,先把系统更新一下。原来装系统的软件安装源都已经404了,正好换成国内的镜像,还能快一点。

# 切换源
sudo vi /etc/apt/sources.list

deb http://mirrors.ustc.edu.cn/raspbian/raspbian/ buster main contrib non-free rpi

:x
sudo apt-get update
sudo apt-get upgrade

这个要更新好久,等安装完,打会游戏去。 为了方便调试,建议打开ssh活着vnc server,可通过 sudo raspi-config 修改相应配置。顺便可以把我那块便携屏的分辨率改成(1280*1920)旋转90度,这样后期贴上单向镜面玻璃,更像镜子。

安装MagicMirror²

官方提供了简单的自动化脚本,可以试一下 https://github.com/sdetweil/MagicMirror_scripts

但是我没用过,我是手动安装的。

  1. 安装NodeJS

树莓派记得选ARM V7版本

# 下载
wget https://nodejs.org/dist/v18.16.1/node-v18.16.1-linux-armv7l.tar.xz
# 解压
xz -d node-v12.13.1-linux-armv7l.tar.xz
tar -xavf node-v18.16.1-linux-armv7l.tar 
# 移除老软连接
sudo rm -rf /usr/bin/node
sudo rm -rf /usr/bin/npm
# 全局命令
sudo mv ./node-v18.16.1-linux-armv7l /usr/local/node
sudo ln -s /usr/local/node/bin/node /usr/bin/node
sudo ln -s /usr/local/node/bin/npm /usr/bin/npm
# 测试
npm -v
9.5.1

node -v
v18.16.1

顺手把npm的淘宝镜像也给整上

vi ~/.npmrc

registry=http://registry.npmmirror.com
:x
  1. 安装程序

没有科学上网的小伙伴可以用代理网站,还挺好用的,在github仓库地址之前加上 https://ghproxy.com/就行了,谁用谁知道。

git clone https://ghproxy.com/https://github.com/MichMich/MagicMirror
cd MagicMirror/
npm run install-mm
cp config/config.js.sample config/config.js

下载依赖还挺慢的,要半个小时左右,吃完饭再回来继续~

  1. 运行

# 本地运行
npm run start

# ssh远程启动屏幕
DISPLAY=:0 nohup npm start &

# 作为服务端运行
npm run server

默认 这样就能看到默认版本的墨镜啦。官网说,按ALT可进入菜单,但我没试出来,小伙伴们可以尝试尝试。

本地化

可以看到现在的默认的效果都不符合国情,日历是美国的,聊天时英文的,新闻拿不到,天气也拿不到。

大部分定制化都可以通过配置完成,只有天气需要一点点开发。具体请参考官方文档-模块. 修改一下安装时复制的config.js后重启就行了。

时钟

改了下时间显示格式

{
    module: "clock",
    position: "top_left",
    config: {
        dateFormat: "YYYY-MM-DD"
    }
}

日历显示假期

我这里找了个国内的通用日历,当然你也可以同步自己的Office日历,苹果日历啥的

{
    // https://github.com/lanceliao/china-holiday-calender
    module: "calendar",
    header: "假期调休",
    position: "top_left",
    config: {
        calendars: [
            {
                fetchInterval: 7 * 24 * 60 * 60 * 1000,
                symbol: "calendar-check",
                showTimeToday: true,
                timeFormat: "relative",
                url: "https://www.shuyz.com/githubfiles/china-holiday-calender/master/holidayCal.ics"
            }
        ]
    }
}

新闻RSS

找了几个自己感兴趣的RSS订阅

{
    module: "newsfeed",
    position: "bottom_bar",
    config: {
        feeds: [
            {
                title: "中新网",
                url: "https://www.chinanews.com.cn/rss/scroll-news.xml"
            },
            {
                title: "美团技术团队",
                url: "https://tech.meituan.com/feed/"
            },
            {
                title: "Microsoft AI Blog",
                url: "https://blogs.microsoft.com/ai/feed/"
            },
        ],
        showDescription: true,
        showSourceTitle: true,
        showPublishDate: true,
        broadcastNewsFeeds: true,
        broadcastNewsUpdates: true
    }
}

天气

我这里用的心知天气的免费接口,只有当天天气,近3天天气,本来还想去一下天气预警,2小时降水预测什么的,结果都用不了,难受。

先去心知天气注册个账号,当然用免费的其他网站提供的接口也可以,总之就是吧取回来的json对象,转换成程序需要的weather对象就行。具体weather对象的参数,参见weather-provider

配置

{
    module: "weather",
    position: "top_right",
    header: "天气",
    config: {
        weatherProvider: "seniverse",
        type: "current",
        lat: 31.245661, 
        lon: 121.419168,
        apiKey: "xxxxx"
    }
},
{
    module: "weather",
    position: "top_right",
    config: {
        weatherProvider: "seniverse",
        type: "daily",
        lat: 31.245661, 
        lon: 121.419168,
        apiKey: "xxxxx"
    }
}

weather-provider:新建一个自定义provider,位于modules/default/weather/providers,主要是修改一下请求接口和对象转换方法就可以了

/* global WeatherProvider, WeatherObject */

/* MagicMirror²
 * Module: Weather
 * 心知天气
 *
 * This class is the blueprint for a weather provider.
 * seniverse
 * 
 */

WeatherProvider.register("seniverse", {
	// Set the name of the provider.
	// This isn't strictly necessary, since it will fallback to the provider identifier
	// But for debugging (and future alerts) it would be nice to have the real name.
	providerName: "seniverse",

	// Set the default config properties that is specific to this provider
	defaults: {
		apiVersion: "v3",
		apiBase: "https://api.seniverse.com/",
		locationID: false,
		location: false,
		lat: 31.245661, // the onecall endpoint needs lat / lon values, it doesn't support the locationId
		lon: 121.419168,
		apiKey: "xxxxx"
	},

	// Overwrite the fetchCurrentWeather method.
	fetchCurrentWeather() {
		this.fetchData(this.getUrl())
			.then((data) => {
				let currentWeather;
				currentWeather = this.generateWeatherObjectFromCurrentWeather(data.results[0]);
				this.setCurrentWeather(currentWeather);
			})
			.catch(function (request) {
				Log.error("Could not load data ... ", request);
			})
			.finally(() => this.updateAvailable());
	},

	// Overwrite the fetchWeatherForecast method.
	fetchWeatherForecast() {
		this.fetchData(this.getUrl())
			.then((data) => {
				if (data.results[0].alarms && data.results[0].alarms.length > 0) {
					forecast = this.generateWeatherObjectsFromForecast(data.results[0].alarms);
					this.setWeatherForecast(forecast);
				}
				if (data.results[0].daily && data.results[0].daily.length > 0) {
					forecast = this.generateWeatherObjectsFromForecast(data.results[0].daily);
					this.setWeatherForecast(forecast);
				}
			})
			.catch(function (request) {
				Log.error("Could not load data ... ", request);
			})
			.finally(() => this.updateAvailable());
	},

	// Overwrite the fetchWeatherHourly method.
	fetchWeatherHourly() {
		this.fetchData(this.getUrl())
			.then((data) => {
				Log.error("Not support");
			})
			.catch(function (request) {
				Log.error("Could not load data ... ", request);
			})
			.finally(() => this.updateAvailable());
	},

	/**
	 * Overrides method for setting config to check if endpoint is correct for hourly
	 * @param {object} config The configuration object
	 */
	setConfig(config) {
		this.config = config;
		if (!this.config.weatherEndpoint) {
			switch (this.config.type) {
				case "hourly":
					this.config.weatherEndpoint = "/weather/hourly.json";
					break;
				case "daily":
					this.config.weatherEndpoint = "/weather/daily.json";
					break;
				case "forecast":
					this.config.weatherEndpoint = "/weather/alarm.json";
					break;
				case "current":
					this.config.weatherEndpoint = "/weather/now.json";
					break;
				default:
					Log.error("weatherEndpoint not configured and could not resolve it based on type");
			}
		}
	},

	/** OpenWeatherMap Specific Methods - These are not part of the default provider methods */
	/*
	 * Gets the complete url for the request
	 */
	getUrl() {
		return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.getParams();
	},

	/*
	 * Generate a WeatherObject based on currentWeatherInformation
	 */
	generateWeatherObjectFromCurrentWeather(currentWeatherData) {
		const currentWeather = new WeatherObject();
		currentWeather.date = moment(currentWeatherData.last_update);
		currentWeather.temperature = currentWeatherData.now.temperature;
		currentWeather.weatherType = this.convertWeatherType(currentWeatherData.now.code);

		return currentWeather;
	},

	/*
	 * Generate WeatherObjects based on forecast information
	 */
	generateWeatherObjectsFromForecast(forecasts) {
		const weather = []
		if(forecasts[0].code_day) return this.generateForecastDaily(forecasts);
		forecasts.forEach(item => {
			const currentWeather = new WeatherObject();
			currentWeather.temperature = currentWeatherData.title;
			currentWeather.weatherType = this.convertWeatherType(currentWeatherData.type);
			weather.push(currentWeather)
		})
		return weather;
	},

	/*
	 * Generate forecast information for 3-hourly forecast (available for free
	 * subscription).
	 */
	generateForecastHourly(forecasts) {
		Log.error("Not implemented yet");
		return [];
	},

	/*
	 * Generate forecast information for daily forecast (available for paid
	 * subscription or old apiKey).
	 */
	generateForecastDaily(forecasts) {
		// initial variable declaration
		const days = [];

		for (const forecast of forecasts) {
			const weather = new WeatherObject();

			weather.date = moment(forecast.date);
			weather.minTemperature = forecast.low;
			weather.maxTemperature = forecast.high;
			weather.weatherType = this.convertWeatherType(forecast.code_day);
			weather.windSpeed = forecast.wind_speed;
			weather.windDirection = forecast.wind_direction_degree;
			weather.humidity = forecast.humidity;
			weather.rain = forecast.rainfall;
			weather.precipitation = forecast.precip;
			days.push(weather);
		}

		return days;
	},

	/*
	 * Convert the OpenWeatherMap icons to a more usable name.
	 */
	convertWeatherType(weatherType) {
		const weatherTypes = {
			0: "day-sunny",
			1: "night-clear",
			2: "day-sunny",
			3: "night-clear",
			4: "cloudy",
			5: "day-cloudy",
			6: "night-alt-cloudy",
			7: "day-cloudy",
			8: "night-alt-cloudy",
			9: "cloud",
			10: "showers",
			11: "thunderstorm",
			12: "rain-mix",
			13: "rain",
			14: "rain",
			15: "rain",
			16: "rain",
			17: "rain",
			18: "rain",
			19: "snow",
			20: "snow",
			21: "day-snow",
			22: "snow",
			23: "snow",
			23: "snow-wind",
			25: "snow-wind",
			26: "dust",
			27: "dust",
			28: "sandstorm",
			29: "sandstorm",
			30: "fog",
			31: "smoke",
			32: "windy",
			33: "windy",
			33: "strong-wind",
			35: "hurricane",
			36: "tornado",
			37: "snowflake-cold",
			38: "day-sunny",
			99: "gale-warning",
			'大风': "gale-warning",
			99: "gale-warning",
			99: "gale-warning",
		};

		return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
	},

	/* getParams(compliments)
	 * Generates an url with api parameters based on the config.
	 *
	 * return String - URL params.
	 */
	getParams() {
		let params = "?";
		// 实时	https://api.seniverse.com/v3/weather/now.json?key=your_api_key&location=beijing&language=zh-Hans&unit=c
		// 小时 https://api.seniverse.com/v3/weather/hourly.json?key=your_api_key&location=beijing&language=zh-Hans&unit=c&start=0&hours=24
		// 天 https://api.seniverse.com/v3/weather/daily.json?key=your_api_key&location=beijing&language=zh-Hans&unit=c&start=-1&days=5
		// 预警 https://api.seniverse.com/v3/weather/alarm.json?key=your_api_key&location=beijing&detail=more
		if (this.config.type === "current") {
			params += "advanced=2.1";
		} else if (this.config.type === "hourly") {
			params += "hours=24";
		} else if (this.config.type === "daily" || this.config.type === "forecast") {
			params += "days=5";
		}  else if (this.config.type === "forecast" ) {
			params += "detail=more";
		}
		if (this.config.lat && this.config.lon) {
			params += `&location=${this.config.lat}:${this.config.lon}&start=0`;
		} else if (this.firstEvent && this.firstEvent.geo) {
			params += `&location=${this.firstEvent.geo.lat}:${this.firstEvent.geo.lon}&start=0`;
		} else {
			// TODO hide doesnt exist!
			params += `&location=Shanghai&start=0`;
		}

		params += "&language=zh-Hans"; // WeatherProviders should use metric internally and use the units only for when displaying data
		params += `&unit=c`;
		params += `&key=${this.config.apiKey}`;

		return params;
	}
});

自定义

07.30 新增了个播放B站视频的功能,自定义组建,iframe嵌入B站播放器,配置BV编号,就能播放视频。健身跟练就方便多啦。

配置

		{
			module: "bilibili", disabled: true,
			position: "fullscreen_above",
			config: {
				bvid: "BV1ZT4y1U7vX",
				top: "40vh",
				startFrom: 60
			}
		},

最简单的自定义组件:

/* MagicMirror²
 * Module: bilibili
 *
 */
Module.register("bilibili", {
	// Default module config.
	defaults: {
		bvid: "BV1ZT4y1U7vX",
		top: "40vh",
		startFrom: 1
	},

	getTemplate: function () {
		return "bilibili.njk";
	},

	getTemplateData: function () {
		return this.config;
	}
});

<!--
	Use ` | safe` to allow html tages within the text string.
	https://mozilla.github.io/nunjucks/templating.html#autoescaping
	自动播放B站视频
-->

<iframe 
	src="//player.bilibili.com/player.html?bvid={{bvid}}&page=1&t={{startFrom}}&high_quality=1" 
	scrolling="no" 
	border="0" 
	frameborder="no" 
	framespacing="0" 
	allowfullscreen="true"
	width="100%"
        height="600px"
	style="position: relative;top: {{top}};"
	sandbox="allow-top-navigation allow-same-origin allow-forms allow-scripts">
</iframe>

其他

视频并不需要一直播放,我只需要锻炼的时候放视频就行了,其他时候希望他隐藏。 为此做了一些手脚

  1. 使用pm2监听配置文件,文件一旦发生变化则自动重启 pm2 start magic_mirror --watch --ignore-watch="node_modules"
  2. 写两个脚本替换配置文件,为了方便替换,上面特地把disablemodule放在了一行
# enable
sed -i 's/module: "bilibili", disabled: true,/module: "bilibili", disabled: false,/g' config/config.js 
# disable
sed -i 's/module: "bilibili", disabled: false,/module: "bilibili", disabled: true,/g' config/config.js 

  1. 为了更方便操作,创建IOS快捷指令远程执行shell,最终实现一键切换

外观改造

淘宝上买一个单向镜面玻璃就完事了,镜框什么的,以后再说。

比较麻烦的是电源,因为树莓派需要电源,显示器也需要电源,加上hdmi线就很臃肿。试了几套方案,最优雅的应该是,用能反向输出电源的屏幕,通过hdmi线给树莓派供电,输出30W就绰绰有余了,或说树莓派还真是省电呀。

书房的大屏幕有输电功能,但是我的便携屏不支持,要专门去买块屏幕我是不舍的。现在拿个拖线板将就一下吧。

效果

材料

效果

成品

  1. wnvCpgrV