├── landscape.jpg
├── portrait.jpg
├── manifest.json
├── README.md
├── params.js
├── network.js
├── main.js
├── screen.js
├── index.html
└── m2-enclosure.scad
/landscape.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/basuke/air-sign/trunk/landscape.jpg
--------------------------------------------------------------------------------
/portrait.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/basuke/air-sign/trunk/portrait.jpg
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "$(MODDABLE)/examples/manifest_base.json",
4 | "$(MODDABLE)/examples/manifest_commodetto.json",
5 | "$(MODDABLE)/examples/manifest_net.json",
6 | "$(MODDABLE)/modules/files/file/manifest.json",
7 | "$(MODDABLE)/modules/files/preference/manifest.json",
8 | "$(MODDABLE)/modules/network/mdns/manifest.json"
9 | ],
10 | "modules": {
11 | "*": [
12 | "./main",
13 | "./network",
14 | "./params",
15 | "./screen",
16 | "$(MODULES)/network/http/*"
17 | ],
18 | "commodetto/readJPEG": "$(COMMODETTO)/commodettoReadJPEG"
19 | },
20 | "resources": {
21 | "*-mask": [
22 | "$(MODDABLE)/examples/assets/fonts/OpenSans-Semibold-16",
23 | "$(MODDABLE)/examples/assets/fonts/OpenSans-Semibold-20",
24 | "$(MODDABLE)/examples/assets/fonts/OpenSans-Semibold-28"
25 | ]
26 | },
27 | "data": {
28 | "*": [
29 | "./index"
30 | ]
31 | },
32 | "preload": [
33 | "http"
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # air-sign
2 | DIY sign board with API embedded. Change the display by sending a HTTP request.
3 |
4 | ## Featrue
5 |
6 | - It's a wireless sign board to display message. i.e. in the office door, kids room
7 | - Change the display contents by sending a HTTP request to the API.
8 | - mDNS supported. You can name it and locate using `some-name`.local instead of IP.
9 |
10 | ## Environment
11 |
12 | - Moddable One https://github.com/Moddable-OpenSource/moddable/blob/public/documentation/devices/moddable-one.md
13 |
14 | ## API
15 |
16 | The host is displayed to the screen on boot time.
17 |
18 | ### Change text
19 |
20 | Change the displayed text.
21 |
22 | Method | Address | BODY | Content-Type
23 | | - | - | - | - |
24 | POST | http://`host`/text | plain text to be displayed. Multi line text is okay with LF. | text/plain
25 |
26 | ### Change text color
27 |
28 | Change the color of text.
29 |
30 | Method | Address | BODY | Content-Type
31 | | - | - | - | - |
32 | POST | http://`host`/color | CSS 2 representation of color. i.e. #fcc, black, white, red | text/plain
33 |
34 | ### Change background color
35 |
36 | Change the color of background.
37 |
38 | Method | Address | BODY | Content-Type
39 | | - | - | - | - |
40 | POST | http://`host`/background | CSS 2 representation of color. i.e. #fcc, black, white, red | text/plain
41 |
42 | ### Change background image
43 |
44 | Change background image.
45 |
46 | Method | Address | BODY | Content-Type
47 | | - | - | - | - |
48 | POST | http://`host`/image | 320 x 240 JPEG image | image/jpeg
49 |
--------------------------------------------------------------------------------
/params.js:
--------------------------------------------------------------------------------
1 | import Preference from "preference";
2 |
3 | export const domain = 'air-sign';
4 |
5 | export const params = {
6 | version: 2,
7 |
8 | read(key) { return Preference.get(domain, key) },
9 | write(key, value) {
10 | if (value === null || value === undefined) {
11 | Preference.delete(domain, key);
12 | } else {
13 | Preference.set(domain, key, value);
14 | }
15 | },
16 |
17 | get text() { return this.read('text') },
18 | get color() { return this.read('color') },
19 | get background() { return this.read('background') },
20 | get font() { return this.read('font') },
21 | get image() { return this.read('image') },
22 |
23 | set text(value) { this.write('text', value) },
24 | set color(value) { this.write('color', value) },
25 | set background(value) { this.write('background', value) },
26 | set font(value) { this.write('font', value) },
27 | set image(value) { this.write('image', value) },
28 |
29 | reset() {
30 | this.text = undefined;
31 | this.color = 'white';
32 | this.background = 'black';
33 | this.font = 'L';
34 | this.image = null;
35 | },
36 |
37 | init() {
38 | const savedVersion = this.read('version');
39 | if (savedVersion != this.version) {
40 | this.write('version', this.version);
41 | this.reset();
42 | }
43 | },
44 |
45 | status() {
46 | return {
47 | text: this.text,
48 | color: this.color,
49 | background: this.background,
50 | font: this.font,
51 | image: this.image,
52 | };
53 | }
54 | };
55 |
56 | export default params;
57 |
--------------------------------------------------------------------------------
/network.js:
--------------------------------------------------------------------------------
1 | import { Server } from "http"
2 | import MDNS from "mdns";
3 | import { File } from "file";
4 |
5 | export function registerHostName(hostName, callback) {
6 | new MDNS({hostName}, (message, value) => {
7 | if (message === MDNS.hostName && callback)
8 | callback(value);
9 | });
10 | }
11 |
12 | const cacheFileName = 'cache';
13 |
14 | export class HandyServer extends Server {
15 | constructor(dictionary) {
16 | super(dictionary);
17 |
18 | const verbose = dictionary.verbose;
19 | const root = dictionary.root;
20 |
21 | let file = undefined;
22 |
23 | const server = this;
24 |
25 | this.callback = function(message, arg1, arg2) {
26 | switch (message) {
27 | case Server.status:
28 | this.method = arg2;
29 | this.path = arg1;
30 | this.contentType = "application/octed";
31 | this.body = undefined;
32 | this.file = undefined;
33 | break;
34 |
35 | case Server.header:
36 | switch (arg1) {
37 | case 'content-type':
38 | this.contentType = arg2;
39 | break;
40 | }
41 | break;
42 |
43 | case Server.headersComplete:
44 | if (this.total >= 1024) {
45 | if (File.exists(root + cacheFileName))
46 | File.delete(root + cacheFileName);
47 | file = new File(root + cacheFileName, true);
48 | return true; // handling by myself
49 | } else {
50 | const [type, subtype] = this.contentType.split('/');
51 | return type === 'text' || subtype === 'x-www-form-urlencoded' ? String : ArrayBuffer;
52 | }
53 |
54 | case Server.requestFragment:
55 | const fragment = this.socket.read(ArrayBuffer, arg1);
56 | file.write(fragment);
57 | break;
58 |
59 | case Server.requestComplete:
60 | if (file) {
61 | file.close();
62 | file = undefined;
63 |
64 | this.file = root + cacheFileName;
65 | } else {
66 | this.body = arg1;
67 | }
68 | break;
69 |
70 | case Server.prepareResponse:
71 | const response = server.onRequest(this);
72 | if (response.data) {
73 | this.data = response.data;
74 | this.position = 0;
75 | }
76 | return response;
77 |
78 | case Server.responseFragment:
79 | if (this.position >= this.data.byteLength)
80 | return;
81 |
82 | const chunk = this.data.slice(this.position, this.position + arg1);
83 | this.position += chunk.byteLength;
84 | return chunk;
85 | }
86 | };
87 | }
88 |
89 | onRequest(request) {
90 | switch (request.method) {
91 | case 'GET':
92 | return this.onGet(request);
93 | case 'POST':
94 | return this.onPost(request);
95 | case 'DELETE':
96 | return this.onDelete(request);
97 | default:
98 | return notFound();
99 | }
100 | }
101 |
102 | onGet({path, contentType, body}) {
103 | return notFound();
104 | }
105 |
106 | onPost({path, contentType, body}) {
107 | return notFound();
108 | }
109 |
110 | onDelete({path, contentType, body}) {
111 | return notFound();
112 | }
113 | };
114 |
115 | function makeHeaders(headers) {
116 | const result = [];
117 | for (const key in headers) {
118 | result.push(key);
119 | result.push(headers[key]);
120 | }
121 | return result;
122 | }
123 |
124 | export function response(body, status, type = undefined, headers = {}) {
125 | if (type !== undefined) headers["Content-Type"] = type;
126 |
127 | let data = null;
128 |
129 | if (body && body.byteLength) {
130 | data = body;
131 | body = true;
132 |
133 | headers["Content-Length"] = data.byteLength;
134 | }
135 |
136 | return {body, data, status, headers: makeHeaders(headers)};
137 | }
138 |
139 | export function textResponse(text, status = 200, headers = {}) {
140 | return response(text, status, "text/plain", headers);
141 | }
142 |
143 | export function htmlResponse(text, status = 200, headers = {}) {
144 | return response(text, status, "text/html; charset=utf-8", headers);
145 | }
146 |
147 | export function jsonResponse(data, status = 200, headers = {}) {
148 | return response(JSON.stringify(data) + "\n", status, "application/json", headers);
149 | }
150 |
151 | export function fileResponse(path, status, type, filename) {
152 | return response(fileObject, status, type, headers);
153 | }
154 |
155 | export function okResponse(status = 200, headers = {}) {
156 | return jsonResponse({success:true}, status, headers);
157 | }
158 |
159 | export function errorResponse(message, status = 400, headers = {}) {
160 | return jsonResponse({success: false, error: message}, status, headers);
161 | }
162 |
163 | export function notFound() {
164 | return errorResponse("Not found", 403);
165 | }
166 |
167 | export function ifTypeIs(expectedTypes, contentType, prepareIt, doIt, update) {
168 | if (!expectedTypes.includes(contentType)) {
169 | return errorResponse(`Content-type must be one of ${expectedTypes.join(', ')}\n`);
170 | }
171 |
172 | const value = prepareIt();
173 | if (value === undefined) return errorResponse("Invalid value");
174 |
175 | doIt(value);
176 | update();
177 | return okResponse();
178 | }
179 |
--------------------------------------------------------------------------------
/main.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2020 Basuke
3 | */
4 |
5 | import Net from "net"
6 | import config from "mc/config";
7 | import Timer from "timer";
8 | import Resource from "Resource";
9 | import {
10 | HandyServer,
11 | ifTypeIs,
12 | htmlResponse,
13 | jsonResponse,
14 | okResponse,
15 | notFound,
16 | registerHostName
17 | } from "network";
18 | import {
19 | getDimention,
20 | checkColor,
21 | makeColor,
22 | draw,
23 | drawOntoJpeg,
24 | fonts,
25 | registerTouchHandler,
26 | } from "screen";
27 | import params from "params";
28 |
29 | const port = config.port ? parseInt(config.port) : 80;
30 | if (!port) {
31 | trace(`Invalid port number: ${config.port}\n`);
32 | return;
33 | }
34 |
35 | let hostName = undefined;
36 |
37 | registerHostName(config.hostname ?? "air-sign", value => {
38 | const isInitialTextDisplayed = params.text == initialText();
39 |
40 | hostName = value;
41 |
42 | if (isInitialTextDisplayed) {
43 | params.text = initialText();
44 | update();
45 | }
46 | });
47 |
48 | function initialText() {
49 | const text = hostName ? `http://${hostName}.local\nor\n` : '';
50 | return text + `http://${Net.get("IP")}`;
51 | }
52 |
53 | function update() {
54 | const {text, image} = params;
55 |
56 | const color = params.color ? makeColor(params.color) : undefined;
57 | const background = (!image && params.background) ? makeColor(params.background) : undefined;
58 | const font = fonts[params.font];
59 |
60 | const task = render => {
61 | if (!image && background !== undefined) {
62 | render.fillRectangle(background, 0, 0, render.width, render.height);
63 | }
64 |
65 | if (text && color !== undefined) {
66 | const lines = text.split("\n");
67 | let y = (render.height - font.height * lines.length) >> 1;
68 |
69 | for (const line of lines) {
70 | render.drawText(line, font, color,
71 | (render.width - render.getTextWidth(line, font)) >> 1, y);
72 | y += font.height;
73 | }
74 | }
75 | };
76 |
77 | if (image) {
78 | if (!drawOntoJpeg(image, task)) {
79 | trace("Failed to render this JPEG.\n");
80 | params.image = null;
81 | Timer.set(update, 0);
82 | }
83 | } else {
84 | draw(task);
85 | }
86 | }
87 |
88 | const server = new HandyServer({
89 | port,
90 | verbose: ('verbose' in config ? !!config.verbose : true),
91 | root: config.file.root,
92 | });
93 |
94 | const textType = 'text/plain';
95 | const formType = 'application/x-www-form-urlencoded';
96 | const imageType = 'image/jpeg';
97 |
98 | server.onGet = ({path}) => {
99 | switch (path) {
100 | case '/':
101 | return htmlResponse(new Resource('index.html'));
102 |
103 | case '/reset':
104 | params.reset();
105 | params.text = initialText();
106 | update();
107 | return okResponse();
108 |
109 | case '/info':
110 | return jsonResponse({
111 | ip: Net.get("IP"),
112 | ssid: Net.get("SSID"),
113 | hostName,
114 | fonts: Object.keys(fonts),
115 | ...getDimention(),
116 | });
117 |
118 | case '/status':
119 | return jsonResponse(params.status());
120 |
121 | default:
122 | return notFound();
123 | }
124 | };
125 |
126 | server.onPost = ({path, contentType, body, file}) => {
127 | const ifText = (prepareIt, doIt) => ifTypeIs([textType, formType], contentType, prepareIt, doIt, update);
128 | const ifImage = (prepareIt, doIt) => ifTypeIs([imageType], contentType, prepareIt, doIt, update);
129 |
130 | switch (path) {
131 | case '/busy': {
132 | const message = body ?? "BUSY!";
133 | return ifText(() => message, value => {
134 | params.text = value;
135 | params.color = 'white';
136 | params.background = 'maroon';
137 | });
138 | }
139 |
140 | case '/open': {
141 | const message = body ?? "Good to go!";
142 | return ifText(() => message, value => {
143 | params.text = value;
144 | params.color = 'white';
145 | params.background = 'green';
146 | });
147 | }
148 |
149 | case '/text':
150 | return ifText(() => body, value => params.text = value);
151 |
152 | case '/color':
153 | return ifText(() => checkColor(body) ? body : undefined, value => params.color = value);
154 |
155 | case '/background':
156 | return ifText(() => checkColor(body) ? body : undefined, value => params.background = value);
157 |
158 | case '/font':
159 | return ifText(() => body in fonts ? body : undefined, value => params.font = value);
160 |
161 | case '/image':
162 | return ifImage(() => file ? file : body, value => params.image = value);
163 |
164 | default:
165 | return notFound();
166 | }
167 | };
168 |
169 | server.onDelete = ({path}) => {
170 | switch (path) {
171 | case '/':
172 | params.text = undefined;
173 | params.color = undefined;
174 | params.background = undefined;
175 | params.font = 'L';
176 | params.image = undefined;
177 | break;
178 |
179 | case '/text':
180 | params.text = undefined;
181 | break;
182 |
183 | case '/color':
184 | params.color = undefined;
185 | break;
186 |
187 | case '/background':
188 | params.background = undefined;
189 | break;
190 |
191 | case '/font':
192 | params.font = 'L';
193 | break;
194 |
195 | case '/image':
196 | params.image = undefined;
197 | break;
198 |
199 | default:
200 | return notFound();
201 | }
202 |
203 | update();
204 | return okResponse();
205 | };
206 |
207 | params.init();
208 | if (!params.text) params.text = initialText();
209 | update();
210 |
211 | const handler = registerTouchHandler({
212 | onTouchBegan({x, y, began, ticks}) {
213 | trace(`onTouchBegan: ${x} ${y} ${began} ${ticks - began}\n`);
214 | },
215 |
216 | onTouchMoved({x, y, began, ticks}) {
217 | trace(`onTouchMoved: ${x} ${y} ${began} ${ticks - began}\n`);
218 | },
219 |
220 | onTouchEnded({x, y, began, ticks}) {
221 | trace(`onTouchEnded: ${x} ${y} ${began} ${ticks - began}\n`);
222 | },
223 | });
224 |
--------------------------------------------------------------------------------
/screen.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2020 Basuke
3 | */
4 |
5 | import config from "mc/config";
6 | import Poco from "commodetto/Poco";
7 | import JPEG from "commodetto/readJPEG";
8 | import { File } from "file";
9 | import Resource from "Resource";
10 | import parseBMF from "commodetto/parseBMF";
11 | import Timer from "timer";
12 | import Time from "time";
13 |
14 | export const fonts = {
15 | S: parseBMF(new Resource("OpenSans-Semibold-16.bf4")),
16 | M: parseBMF(new Resource("OpenSans-Semibold-20.bf4")),
17 | L: parseBMF(new Resource("OpenSans-Semibold-28.bf4")),
18 | };
19 |
20 |
21 | if (screen.rotation !== undefined) {
22 | screen.rotation = 270;
23 | }
24 |
25 | export const render = new Poco(screen, { displayListLength: 2048 });
26 |
27 | export function getDimention() {
28 | return { width: render.width, height:render.height };
29 | }
30 |
31 | export function draw(task) {
32 | render.begin();
33 | task(render);
34 | render.end();
35 | }
36 |
37 | function hex(str, offset, count) {
38 | let result = parseInt(str.substring(offset, offset + count), 16);
39 | if (count === 1)
40 | result *= 17;
41 | return result;
42 | }
43 |
44 | function rgba(name) {
45 | if (!name) return false;
46 |
47 | const cssColors = {
48 | black: [0x00, 0x00, 0x00, 0xff],
49 | silver: [0xc0, 0xc0, 0xc0, 0xff],
50 | gray: [0x80, 0x80, 0x80, 0xff],
51 | white: [0xff, 0xff, 0xff, 0xff],
52 | maroon: [0x80, 0x00, 0x00, 0xff],
53 | red: [0xff, 0x00, 0x00, 0xff],
54 | purple: [0x80, 0x00, 0x80, 0xff],
55 | fuchsia: [0xff, 0x00, 0xff, 0xff],
56 | green: [0x00, 0x80, 0x00, 0xff],
57 | lime: [0x00, 0xff, 0x00, 0xff],
58 | olive: [0x80, 0x80, 0x00, 0xff],
59 | yellow: [0xff, 0xff, 0x00, 0xff],
60 | navy: [0x00, 0x00, 0x80, 0xff],
61 | blue: [0x00, 0x00, 0xff, 0xff],
62 | teal: [0x00, 0x80, 0x80, 0xff],
63 | aqua: [0x00, 0xff, 0xff, 0xff],
64 | orange: [0xff, 0xa5, 0x00, 0xff],
65 | transparent: [0x00, 0x00, 0x00, 0x0],
66 | };
67 |
68 | if (cssColors[name]) {
69 | return cssColors[name];
70 | }
71 |
72 | let red = 0, green = 0, blue = 0, alpha = 255;
73 | if (name[0] === '#') {
74 | switch (name.length) {
75 | case 5:
76 | alpha = hex(name, 4, 1);
77 | case 4:
78 | red = hex(name, 1, 1);
79 | green = hex(name, 2, 1);
80 | blue = hex(name, 3, 1);
81 | break;
82 |
83 | case 9:
84 | alpha = hex(name, 7, 2);
85 | case 7:
86 | red = hex(name, 1, 2);
87 | green = hex(name, 3, 2);
88 | blue = hex(name, 5, 2);
89 | break;
90 |
91 | default:
92 | return false;
93 | }
94 | }
95 | return [red, green, blue, alpha];
96 | }
97 |
98 | export function makeColor(name) {
99 | return render.makeColor(...(rgba(name) ?? [0, 0, 0, 0]));
100 | }
101 |
102 | export function checkColor(name) {
103 | return !!rgba(name);
104 | }
105 |
106 | export function drawOntoJpeg(path, task) {
107 | try {
108 | const jpeg = new JPEG();
109 | let file = new File(path);
110 |
111 | while (file) {
112 | while (file) {
113 | const bytes = file.read(ArrayBuffer, 1024);
114 | jpeg.push(bytes);
115 | if (file.position === file.length) {
116 | jpeg.push();
117 | file.close();
118 | file = undefined;
119 | }
120 | if (jpeg.ready) break;
121 | }
122 |
123 | while (jpeg.ready) {
124 | const block = jpeg.read();
125 | render.begin(block.x, block.y, block.width, block.height);
126 | render.drawBitmap(block, block.x, block.y);
127 | task(render);
128 | render.end();
129 | }
130 | }
131 | } catch (error) {
132 | trace(`screen.js: ${error}\n`);
133 | return false;
134 | }
135 |
136 | return true;
137 | }
138 |
139 | const range = n => Array.from(new Array(n).keys());
140 |
141 | export function registerTouchHandler(handlers, interval=10) {
142 | if (!config.Touch)
143 | return;
144 |
145 | const touch = new config.Touch;
146 | const touchCount = config.touchCount ? config.touchCount : 1;
147 | const points = range(touchCount).map(index => ({index}));
148 | const rotate = rotationFunction(screen);
149 |
150 | const timer = Timer.repeat(() => {
151 | touch.read(points);
152 | const ticks = Time.ticks;
153 |
154 | for (const point of points) {
155 | switch (point.state) {
156 | case 3:
157 | if (rotate) rotate(point);
158 | // fallthrough
159 | case 0:
160 | if (point.began) {
161 | point.ticks = ticks;
162 | handlers.onTouchEnded(point);
163 | delete point.began;
164 | delete point.ticks;
165 | delete point.x;
166 | delete point.y;
167 | }
168 | break;
169 |
170 | case 1:
171 | case 2:
172 | if (rotate) rotate(point);
173 |
174 | if (point.began) {
175 | point.ticks = ticks;
176 | handlers.onTouchMoved(point);
177 | } else {
178 | point.ticks = point.began = ticks;
179 | handlers.onTouchBegan(point);
180 | }
181 | break;
182 | }
183 | }
184 | }, interval);
185 |
186 | return {
187 | touch,
188 | timer,
189 | touchCount,
190 | points,
191 | };
192 | }
193 |
194 | function rotationFunction(screen) {
195 | const {width, height} = screen;
196 |
197 | switch (screen.rotation) {
198 | case 90:
199 | return point => {
200 | const x = point.x;
201 | point.x = point.y;
202 | point.y = height - x;
203 | };
204 | case 180:
205 | return point => {
206 | point.x = width - point.x;
207 | point.y = height - point.y;
208 | };
209 | case 270:
210 | return point => {
211 | const x = point.x;
212 | point.x = width - point.y;
213 | point.y = x;
214 | };
215 | default:
216 | return undefined;
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | air-sign
5 |
6 |
7 |
8 |
air-sign
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
Custom message
17 |
18 |
19 |
20 |
21 |
22 |
Text Color
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
Background Color
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
133 |
134 |
232 |
233 |
234 |
--------------------------------------------------------------------------------
/m2-enclosure.scad:
--------------------------------------------------------------------------------
1 | Flip = true;
2 | SnapRadius = 0.7;
3 | SnapCount = 4;
4 | SnapMargin = 20;
5 |
6 | module enclosure() {
7 |
8 | C = 0.25; // clearance
9 | T = 2; // basic thickness
10 | S = 2; // space between base and Moddable Two
11 |
12 | // Body and PCB board
13 | body_width = 81.73;
14 | body_height = 47.37;
15 | body_depth = 4.64;
16 |
17 | // LCD panel
18 | panel_width = 66.68;
19 | panel_height = 44.45;
20 | panel_depth = 1.04;
21 | panel_offset_left = 9.04;
22 | panel_offset_top = 1.80;
23 |
24 | // Screen
25 | screen_width = 49.48;
26 | screen_height = 37.11;
27 | screen_offset_left = 11.55;
28 | screen_offset_top = 3.38;
29 |
30 | // Entire dimmension
31 | max_width = body_width;
32 | max_height = body_height;
33 | max_depth = 8.42;
34 |
35 | // Mount hole
36 | hole_diameter = 2.5;
37 | hole_radius = hole_diameter / 2;
38 | hole_offset_left = 1.47;
39 | hole_offset_top = 1.60;
40 | hole_offset_right = 1.2;
41 | hole_offset_bottom = 1.2;
42 |
43 | // Bigger pins
44 | pin_top = 7.78;
45 |
46 | // Whole model
47 | // adjustment. make left and right side equal width.
48 | left_width = T + C + panel_offset_left + screen_offset_left;
49 |
50 | width = left_width * 2 + screen_width; // max_width + C * 2 + T * 2;
51 | height = max_height + C * 2 + T * 2;
52 | depth = max_depth + C * 2 + T * 2 + S;
53 |
54 | module rCutOut(radius=T, height=1, rotate=0) {
55 | D = 0.1;
56 | h = height + D * 2;
57 | rotate([0, 0, 180 + rotate])
58 | translate([-radius, -radius, 0])
59 | difference() {
60 | translate([0, 0, -D]) cube([radius + D, radius + D, h]);
61 | translate([0, 0, -D * 2]) cylinder(r=T, h=h + D * 2, $fn=60);
62 | }
63 | }
64 |
65 | module snap() {
66 | radius = SnapRadius;
67 | dx = (width - SnapMargin * 2) / (SnapCount - 1);
68 |
69 | for (i=[0:SnapCount - 1]) {
70 | translate([SnapMargin + i * dx, 0, 0])
71 | sphere(r=radius, $fn=20);
72 | }
73 |
74 | }
75 |
76 | module cover(flip=false) {
77 | module screenHole() {
78 | function scaleForAngle(length, height, angle) =
79 | (length + 2 * height * tan(angle)) / length;
80 | function scalesForAngle(width, height, depth, angle) =
81 | [scaleForAngle(width, depth, angle), scaleForAngle(height, depth, angle)];
82 |
83 | left = T + C + panel_offset_left + screen_offset_left;
84 | top = T + C + panel_offset_top + screen_offset_top;
85 | scale = scalesForAngle(screen_width, screen_height, T, 45);
86 |
87 | translate([left + C, top + C, 0])
88 | translate([screen_width / 2, screen_height / 2, T])
89 | rotate([180, 0, 0])
90 | linear_extrude(height=T, scale=scale)
91 | square([screen_width, screen_height], center=true);
92 | }
93 |
94 | module front() {
95 | difference() {
96 | cube([width, height, T]);
97 | translate([0, 0, -0.001]) scale([1, 1, 1.01]) screenHole();
98 | }
99 | }
100 |
101 | module top() {
102 | cube([width, T, depth]);
103 | }
104 |
105 | module bottom() {
106 | translate([0, height - T, 0]) top();
107 | }
108 |
109 | module right() {
110 | translate([width - T, 0, 0]) cube([T, height, depth]);
111 | }
112 |
113 | module mounterPole(isLeft, isTop) {
114 | function mounterPosition(left, top) =
115 | let (
116 | d = T + C,
117 | r = hole_radius,
118 | left = d + hole_offset_left + r,
119 | top = d + hole_offset_top + r,
120 | right = max_width + d - hole_offset_right - r,
121 | bottom = max_height + d - hole_offset_bottom - r
122 | )
123 | isLeft
124 | ? isTop
125 | ? [left, top, 0]
126 | : [left, bottom, 0]
127 | : isTop
128 | ? [right, top, 0]
129 | : [right, bottom, 0];
130 |
131 | h = T + panel_depth + body_depth + 2;
132 | r = hole_radius * 0.9;
133 |
134 | translate(mounterPosition())
135 | cylinder(r=hole_radius, h=h);
136 | }
137 |
138 | module rightStopper() {
139 | y = 10;
140 | stopper_height = T + C + panel_depth + body_depth + T;
141 | translate([T + C + max_width, (height - y) / 2, 0]) union() {
142 | cube([T, y, stopper_height]);
143 | translate([-T, 0, stopper_height - T]) cube([T * 2, y, T]);
144 | }
145 | }
146 |
147 | module leftStopper(isTop) {
148 | radius = 1;
149 | length = 8;
150 | z = T + C + panel_depth + body_depth + radius;
151 |
152 | module body() {
153 | translate([T + C, T, z])
154 | rotate([0, 90, 0])
155 | cylinder(r=radius, h=length, $fn=60);
156 | }
157 |
158 | if (isTop)
159 | body();
160 | else
161 | translate([0, max_height + C * 2, 0])
162 | body();
163 | }
164 |
165 | module base() {
166 | difference() {
167 | union() {
168 | front();
169 | top();
170 | bottom();
171 | right();
172 |
173 | rightStopper();
174 | leftStopper(true);
175 | leftStopper(false);
176 |
177 | translate([0, T, depth - T / 2]) snap();
178 | translate([0, height - T, depth - T / 2]) snap();
179 | }
180 | rCutOut(height=depth);
181 | translate([width, 0, 0]) rCutOut(height=depth, rotate=90);
182 | translate([width, height, 0]) rCutOut(height=depth, rotate=180);
183 | translate([0, height, 0]) rCutOut(height=depth, rotate=270);
184 | }
185 | }
186 |
187 | if (flip)
188 | translate([0, height * 1.2, 0])
189 | base();
190 | else
191 | translate([0, height, depth])
192 | rotate([180, 0, 0])
193 | base();
194 | }
195 |
196 | module base() {
197 | base_height = height - T * 2;
198 |
199 | module usbCutOut() {
200 | cutout_width = 15;
201 | cutout_height = 20; // was 10, but make cutout through top for convenience.
202 | cutout_center_from_bottom = 11.60;
203 | usb_height = 3.60;
204 |
205 | cutout_y = cutout_center_from_bottom - cutout_width / 2;
206 | cutout_z = depth - T - panel_depth - body_depth - usb_height / 2 - cutout_height / 2;
207 |
208 | translate([0, cutout_y, cutout_z])
209 | cube([T, cutout_width, cutout_height]);
210 | }
211 |
212 | module left() { // USB Connector side
213 | translate([0, T + C, 0])
214 | difference() {
215 | cube([T, base_height - C * 2, depth - T]);
216 | scale([1.1, 1, 1])
217 | translate([-0.01, 0, 0])
218 | usbCutOut();
219 | }
220 | }
221 |
222 | module right() { // To hold the cover
223 | translate([width - T * 2, T + C, 0])
224 | cube([T, base_height - C * 2, depth - T]);
225 | }
226 |
227 | module back() {
228 | module cutOut() {
229 | dx = 12;
230 | dy = 8;
231 |
232 | translate([dx, dy, 0])
233 | cube([width - T - dx * 2, base_height - dy * 2, T]);
234 | }
235 |
236 | translate([0, T, 0])
237 | difference() {
238 | cube([width - T, base_height, T]);
239 | scale([1, 1, 1.1]) translate([0, 0, -0.01]) cutOut();
240 | translate([0, 0, T / 2]) snap();
241 | translate([0, height - 2 * T, T / 2]) snap();
242 | }
243 | }
244 |
245 | union() {
246 | back();
247 | left();
248 | right();
249 | }
250 | }
251 |
252 | color("white") cover(flip=Flip);
253 | color("green") base();
254 | }
255 |
256 | enclosure();
257 |
--------------------------------------------------------------------------------