commit 831b514c0a5ee820556d9e5a3579a5474b1ac278 Author: Dirk Jahnke Date: Wed May 29 15:44:54 2019 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbdd36c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.pio +.clang_complete +.gcc-flags.json diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7c486f1 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,67 @@ +# Continuous Integration (CI) is the practice, in software +# engineering, of merging all developer working copies with a shared mainline +# several times a day < https://docs.platformio.org/page/ci/index.html > +# +# Documentation: +# +# * Travis CI Embedded Builds with PlatformIO +# < https://docs.travis-ci.com/user/integration/platformio/ > +# +# * PlatformIO integration with Travis CI +# < https://docs.platformio.org/page/ci/travis.html > +# +# * User Guide for `platformio ci` command +# < https://docs.platformio.org/page/userguide/cmd_ci.html > +# +# +# Please choose one of the following templates (proposed below) and uncomment +# it (remove "# " before each line) or use own configuration according to the +# Travis CI documentation (see above). +# + + +# +# Template #1: General project. Test it using existing `platformio.ini`. +# + +# language: python +# python: +# - "2.7" +# +# sudo: false +# cache: +# directories: +# - "~/.platformio" +# +# install: +# - pip install -U platformio +# - platformio update +# +# script: +# - platformio run + + +# +# Template #2: The project is intended to be used as a library with examples. +# + +# language: python +# python: +# - "2.7" +# +# sudo: false +# cache: +# directories: +# - "~/.platformio" +# +# env: +# - PLATFORMIO_CI_SRC=path/to/test/file.c +# - PLATFORMIO_CI_SRC=examples/file.ino +# - PLATFORMIO_CI_SRC=path/to/test/directory +# +# install: +# - pip install -U platformio +# - platformio update +# +# script: +# - platformio ci --lib="." --board=ID_1 --board=ID_2 --board=ID_N diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d4ccb1 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# 7-Segment FastClock / Counter Display + +This is a controller for a LED driven 7-segment clock/counter display. An ESP8266 offers WiFi access for configuration/setting purpose and the ability to retrieve the current time through NTP. + +But it might as well be used as a fast clock display for model railroads. + +Prerequisites: + +* WS-2812 based adressable LED chain forming the 7-segment display +* number of LEDs per segment is configurable + + + +## Links / References + +- example of 3d printable segment frames diff --git a/include/README b/include/README new file mode 100644 index 0000000..194dcd4 --- /dev/null +++ b/include/README @@ -0,0 +1,39 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the usual convention is to give header files names that end with `.h'. +It is most portable to use only letters, digits, dashes, and underscores in +header file names, and at most one dot. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..6debab1 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into executable file. + +The source code of each library should be placed in a an own separate directory +("lib/your_library_name/[here are source files]"). + +For example, see a structure of the following two libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +and a contents of `src/main.c`: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +PlatformIO Library Dependency Finder will find automatically dependent +libraries scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..6af2a57 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,20 @@ +;PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:esp12e] +platform = espressif8266 +board = d1_mini +framework = arduino +upload_port = /dev/cu.wchusbserial1420 + +[lib_deps] +library = + WifiManager, + Adafruit NeoPixel diff --git a/src/SevenSegmentClock.cpp b/src/SevenSegmentClock.cpp new file mode 100644 index 0000000..17b0682 --- /dev/null +++ b/src/SevenSegmentClock.cpp @@ -0,0 +1,228 @@ +#include "SevenSegmentClock.h" + +static const uint16_t PixelCount = 4*7*3+3; + +#define colorSaturation 63 + +// Seven Segment Layout: 3 LEDs per segment +// order of segments: +// b +// --- +// a| |c +// --- d +// e| |g +// --- +// f +#define SegmentsPerDigit 7 +#define LedsPerSegment 3 +#define LedsPerDigit (SegmentsPerDigit * LedsPerSegment) +#define SeperatorLeds 3 /* num of leds as seperation between hours/mins */ +#define SegOffset_a 0 +#define SegOffset_b LedsPerSegment +#define SegOffset_c LedsPerSegment*2 +#define SegOffset_d LedsPerSegment*3 +#define SegOffset_e LedsPerSegment*4 +#define SegOffset_f LedsPerSegment*5 +#define SegOffset_g LedsPerSegment*6 +static const uint8_t digitOffset[] = { 0, LedsPerDigit, 2*LedsPerDigit+SeperatorLeds, 3*LedsPerDigit+SeperatorLeds }; + +#define Seg_a 0x01 +#define Seg_b 0x02 +#define Seg_c 0x04 +#define Seg_d 0x08 +#define Seg_e 0x10 +#define Seg_f 0x20 +#define Seg_g 0x40 + +#define decimalPointLed (2*LedsPerDigit) +#define clockSeperatorLed1 (2*LedsPerDigit+1) +#define clockSeperatorLed2 (2*LedsPerDigit+2) + +#define firstCharacterMapped 32u /* first char to be mapped is "space" */ +#define lastCharacterMapped (sizeof(charMapping) + firstCharacterMapped) + +static const unsigned char PROGMEM charMapping[] = { + /* 0x20, space */ 0, + /* ! */ 0, + /* " */ 0, + /* # */ 0, + /* $ */ 0, + /* % */ 0, + /* & */ 0, + /* ' */ 0, + /* ( */ Seg_a + Seg_b + Seg_e + Seg_f, + /* ) */ Seg_b + Seg_c + Seg_f + Seg_g, + /* * */ 0, + /* + */ 0, + /* - */ Seg_d, + /* . */ 0, + /* / */ Seg_e, + /* 0 */ Seg_a + Seg_b + Seg_c + Seg_e + Seg_f + Seg_g, + /* 1 */ Seg_c + Seg_g, + /* 2 */ Seg_b + Seg_c + Seg_d + Seg_e + Seg_f, + /* 3 */ Seg_b + Seg_c + Seg_d + Seg_f + Seg_g, + /* 4 */ Seg_a + Seg_c + Seg_d + Seg_g, + /* 5 */ Seg_a + Seg_b + Seg_d + Seg_f + Seg_g, + /* 6 */ Seg_a + Seg_d + Seg_e + Seg_f + Seg_g, + /* 7 */ Seg_b + Seg_c + Seg_g, + /* 8 */ Seg_a + Seg_b + Seg_c + Seg_d + Seg_e + Seg_f + Seg_g, + /* 9 */ Seg_a + Seg_b + Seg_c + Seg_d + Seg_g, + /* : */ 0, + /* ; */ 0, + /* < */ 0, + /* = */ Seg_d + Seg_e, + /* > */ 0, + /* ? */ 0, + /* @ */ 0, + /* A */ Seg_a + Seg_b + Seg_c + Seg_d + Seg_e + Seg_g, + /* B */ Seg_a + Seg_b + Seg_c + Seg_d + Seg_e + Seg_f + Seg_g, + /* C */ Seg_a + Seg_b + Seg_e + Seg_f, + /* D */ Seg_a + Seg_b + Seg_c + Seg_e + Seg_f + Seg_g, + /* E */ Seg_a + Seg_b + Seg_d + Seg_e + Seg_f, + /* F */ Seg_a + Seg_b + Seg_d + Seg_e, + /* G */ Seg_a + Seg_b + Seg_d + Seg_e + Seg_f + Seg_g, + /* h */ Seg_a + Seg_d + Seg_e + Seg_g, + /* I */ Seg_a + Seg_e, + /* J */ Seg_b + Seg_c + Seg_f + Seg_g, + /* K */ Seg_a + Seg_c + Seg_d + Seg_e + Seg_g, + /* L */ Seg_a + Seg_e + Seg_f, + /* m */ Seg_d + Seg_e + Seg_g, + /* n */ Seg_d + Seg_e + Seg_g, + /* o */ Seg_d + Seg_e + Seg_f + Seg_g, + /* P */ Seg_a + Seg_b + Seg_c + Seg_d + Seg_e, + /* q */ Seg_a + Seg_b + Seg_c + Seg_d + Seg_g, + /* r */ Seg_d + Seg_e, + /* S */ Seg_a + Seg_b + Seg_d + Seg_f + Seg_g, + /* t */ Seg_a + Seg_d + Seg_e + Seg_f, + /* U */ Seg_a + Seg_c + Seg_e + Seg_f + Seg_g, + /* v */ Seg_e + Seg_f + Seg_g, + /* w */ Seg_e + Seg_f + Seg_g, + /* X */ Seg_a + Seg_c + Seg_d + Seg_e + Seg_g, + /* Y */ Seg_a + Seg_c + Seg_d + Seg_g, + /* Z */ Seg_b + Seg_c + Seg_d + Seg_e + Seg_f, + /* [ */ Seg_a + Seg_b + Seg_e + Seg_f, + /* \ */ Seg_a + Seg_d + Seg_g, + /* ] */ Seg_b + Seg_c + Seg_f + Seg_g, + /* ^ */ Seg_a + Seg_b + Seg_c, + /* _ */ Seg_e, + /* 3 hor. bars */ Seg_b + Seg_d + Seg_e, + /* 2 hor. bars, top */ Seg_b + Seg_d, + /* 1 hor. bar, top */ Seg_b, + /* || */ Seg_a + Seg_c + Seg_e + Seg_g +}; + +void SevenSegmentClock::displaySegment(unsigned int ledAddress, uint32_t color) { + //Serial.print("displaySegment led="); Serial.print(ledAddress); Serial.print(" color=0x"); Serial.println(color, HEX); + for (int i=0; isetPixelColor(ledAddress + i, color); + } +} + +void SevenSegmentClock::displayDigit(unsigned int digitNum, char charToDisplay) { + unsigned int c = charToDisplay; + uint32_t color; + + //Serial.print("displayDigit: digitNum="); Serial.print(digitNum); Serial.print(" char=0x"); Serial.println(charToDisplay, HEX); + if (digitNum < 0 || digitNum > 3) { + Serial.print("SevenSegmentClock::displayDigit: Invalid digit num "); + Serial.println(digitNum); + return; + } + int offset = digitOffset[digitNum]; + //Serial.print("1st LED address="); Serial.println(offset); + if (c < firstCharacterMapped || c > lastCharacterMapped) { + Serial.print("ERROR: SevenSegmentClock::displayDigit - Cannot display character 0x"); + Serial.print(c, HEX); + Serial.print(" at digit position "); + Serial.println(digitNum); + return; + } + c -= firstCharacterMapped; + //Serial.print("Check char mapping at index="); Serial.println(c); + unsigned char mapping = pgm_read_byte(charMapping + c); + //Serial.print("Char mapping="); Serial.println(mapping, HEX); + color = (mapping & Seg_a) ? currentColor : black; + displaySegment(offset + SegOffset_a, color); + color = (mapping & Seg_b) ? currentColor : black; + displaySegment(offset + SegOffset_b, color); + color = (mapping & Seg_c) ? currentColor : black; + displaySegment(offset + SegOffset_c, color); + color = (mapping & Seg_d) ? currentColor : black; + displaySegment(offset + SegOffset_d, color); + color = (mapping & Seg_e) ? currentColor : black; + displaySegment(offset + SegOffset_e, color); + color = (mapping & Seg_f) ? currentColor : black; + displaySegment(offset + SegOffset_f, color); + color = (mapping & Seg_g) ? currentColor : black; + displaySegment(offset + SegOffset_g, color); +} + +void SevenSegmentClock::displaySeperator(char seperatorCharacter) { + //Serial.print("displaySeperator: seperator="); Serial.println(seperatorCharacter); + switch (seperatorCharacter) { + case '.': + case ',': + strip->setPixelColor(decimalPointLed, currentColor); + strip->setPixelColor(clockSeperatorLed1, black); + strip->setPixelColor(clockSeperatorLed2, black); + break; + case ':': + strip->setPixelColor(decimalPointLed, black); + strip->setPixelColor(clockSeperatorLed1, currentColor); + strip->setPixelColor(clockSeperatorLed2, currentColor); + break; + default: + strip->setPixelColor(decimalPointLed, black); + strip->setPixelColor(clockSeperatorLed1, black); + strip->setPixelColor(clockSeperatorLed2, black); + Serial.print("SevenSegmentClock::displaySeperator: Unknown character to be displayed: "); + Serial.println(seperatorCharacter); + break; + } +} + +void SevenSegmentClock::displayTime(int hour, int minute) { + char displayText[4]; + clockHour = hour; + clockMinute = minute; + Serial.print("SevenSegmentClock: new time "); Serial.print(clockHour); Serial.print(":"); Serial.println(clockMinute); + displayText[0] = (hour > 9) ? '0' + (hour/10) : ' '; + displayText[1] = '0' + hour % 10; + displayText[2] = '0' + minute / 10; + displayText[3] = '0' + minute % 10; + displayDigit(0, displayText[0]); + displayDigit(1, displayText[1]); + displayDigit(2, displayText[2]); + displayDigit(3, displayText[3]); + displaySeperator(':'); + strip->show(); + Serial.print("Shown: "); Serial.print(displayText[0]); Serial.print(displayText[1]); Serial.print(':'); Serial.print(displayText[2]); Serial.println(displayText[3]); +}; + +uint32_t SevenSegmentClock::red, SevenSegmentClock::green, SevenSegmentClock::blue, SevenSegmentClock::white, SevenSegmentClock::black; +uint8_t SevenSegmentClock::LedDataPin; +Adafruit_NeoPixel *SevenSegmentClock::strip; + +void SevenSegmentClock::begin(void) { + Serial.println("Init Neopixels ..."); + Serial.print("LED pin="); Serial.println(LedDataPin); + Serial.print("Pixels="); Serial.println(PixelCount); + SevenSegmentClock::strip = new Adafruit_NeoPixel(PixelCount, LedDataPin, NEO_GRB + NEO_KHZ800); + strip->begin(); + SevenSegmentClock::red = strip->Color(colorSaturation, 0, 0); + SevenSegmentClock::green = strip->Color(0, colorSaturation, 0); + SevenSegmentClock::blue = strip->Color(0, 0, colorSaturation); + SevenSegmentClock::white = strip->Color(colorSaturation, colorSaturation, colorSaturation); + SevenSegmentClock::black = strip->Color(0, 0, 0); + SevenSegmentClock::currentColor = SevenSegmentClock::white; + // strip->show(); + // boot animation + uint32_t colors[] = { red, green, blue, white }; + unsigned int colorIndex = 0; + for (int i=0; isetPixelColor(i, colors[colorIndex++]); + if (colorIndex > sizeof(colors)) colorIndex = 0; + } + strip->show(); + delay(2000); +} diff --git a/src/SevenSegmentClock.h b/src/SevenSegmentClock.h new file mode 100644 index 0000000..500c08d --- /dev/null +++ b/src/SevenSegmentClock.h @@ -0,0 +1,33 @@ +#ifndef sevenSegmentClock_h_included +#define sevenSegmentClock_h_included + +#include + +// avoid flickering of the display: +#define TIME_BETWEEN_DISPLAY_UPDATES_ms 300 +#define BLINK_ON_OFF_TIME_ms 1000 +#define defaultLedDataPin 2 +class SevenSegmentClock { +public: + SevenSegmentClock() { LedDataPin=defaultLedDataPin; init(); }; + SevenSegmentClock(uint8_t dataPin) { LedDataPin=dataPin; init(); }; + void begin(void); + void displayTime(int hour, int minute); + //void setClockSpeed(int _msPerModelSecond) { msPerModelSecond = _msPerModelSecond; setClockSpeed("x"); }; + void setClockHalted(bool halted) { clockHalted = halted; }; + static uint32_t red, green, blue, white, black; + enum ClockDisplayStatus { Off, Booting, Halted, StandardClock, FastClock }; + void displayDigit(unsigned int digitNum, char c); + void displaySeperator(char seperatorCharacter); +private: + void init(void) { displayStatus = Off; clockHour=12; clockMinute=34; setClockHalted(true); }; + static uint8_t LedDataPin; + static Adafruit_NeoPixel *strip; + ClockDisplayStatus displayStatus; + int clockHour; + int clockMinute; + bool clockHalted; + uint32_t currentColor; + void displaySegment(unsigned int ledAddress, uint32_t color); +}; +#endif diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..c48a855 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,191 @@ +#include +#include //this needs to be first, or it all crashes and burns... +#include //https://github.com/esp8266/Arduino + +//needed for library +#include +#include +#include + +#include +#include +#include "SevenSegmentClock.h" + +#define USE_CONFIG false + +static const char *appName = "FastclockClient7Seg"; + +#define MAX_CLOCK_NAME_LEN 16 +#define DEFAULT_CLOCK_CHANNEL 1 + +SevenSegmentClock sevenSegmentClock; + +char static_ip[16] = "10.0.1.56"; +char static_gw[16] = "10.0.1.1"; +char static_sn[16] = "255.255.255.0"; + +char clockName[MAX_CLOCK_NAME_LEN+1] = "fastclk"; +uint8_t clockChannel = DEFAULT_CLOCK_CHANNEL; + +//flag for saving data +bool shouldSaveConfig = false; + +//callback notifying us of the need to save config +void saveConfigCallback () { + Serial.println("Should save config"); + shouldSaveConfig = true; +} + + +void setupWifiConnection() { + WiFiManager wifiManager; + + //set config save notify callback + wifiManager.setSaveConfigCallback(saveConfigCallback); + + //set static ip + IPAddress _ip,_gw,_sn; + _ip.fromString(static_ip); + _gw.fromString(static_gw); + _sn.fromString(static_sn); + + //wifiManager.setSTAStaticIPConfig(_ip, _gw, _sn); + + //add all your parameters here + //**wifiManager.addParameter(&custom_mqtt_server); + //**wifiManager.addParameter(&custom_mqtt_port); + //wifiManager.addParameter(&custom_blynk_token); + + //reset settings - for testing + //wifiManager.resetSettings(); + + //set minimu quality of signal so it ignores AP's under that quality + //defaults to 8% + wifiManager.setMinimumSignalQuality(); + + Serial.println("Starting autoConnect ..."); + if (!wifiManager.autoConnect("FastclockClient7Seg", "password")) { + Serial.println("failed to connect and hit timeout"); + delay(3000); + //reset and try again, or maybe put it to deep sleep + ESP.reset(); + delay(5000); + } + + //if you get here you have connected to the WiFi + Serial.println("connected...yeey :)"); + + //save the custom parameters to FS + #if USE_CONFIG + if (shouldSaveConfig) { + Serial.println("saving config"); + DynamicJsonDocument jsonBuffer(2048); + JsonObject json = jsonBuffer.createObject(); + //**json["mqtt_server"] = mqtt_server; + //**json["mqtt_port"] = mqtt_port; + //json["blynk_token"] = blynk_token; + + json["ip"] = WiFi.localIP().toString(); + json["gateway"] = WiFi.gatewayIP().toString(); + json["subnet"] = WiFi.subnetMask().toString(); + + File configFile = SPIFFS.open("/config.json", "w"); + if (!configFile) { + Serial.println("failed to open config file for writing"); + } + + serializeJsonPretty(json, Serial); + serializeJson(json, configFile); + configFile.close(); + //end save + } + #endif + + Serial.print("local ip: "); Serial.println(WiFi.localIP()); + Serial.print("gateway: "); Serial.println(WiFi.gatewayIP()); + Serial.print("subnet: "); Serial.println(WiFi.subnetMask()); +} + +void setup() { + // if coming from deep sleep, we just go to sleep again + + // put your setup code here, to run once: + Serial.begin(115200); + Serial.print("Starting *** "); Serial.println(appName); + Serial.println(ESP.getResetReason()); + + //clean FS, for testing + //SPIFFS.format(); + + //read configuration from FS json + Serial.println("mounting FS..."); + + if (SPIFFS.begin()) { + Serial.println("mounted file system"); +#if USE_CONFIG + if (SPIFFS.exists("/config.json")) { + //file exists, reading and loading + Serial.println("reading config file"); + File configFile = SPIFFS.open("/config.json", "r"); + if (configFile) { + Serial.println("opened config file"); + size_t size = configFile.size(); + // Allocate a buffer to store contents of the file. + std::unique_ptr buf(new char[size]); + + configFile.readBytes(buf.get(), size); + DynamicJsonDocument jsonBuffer(2048); + JsonObject json = jsonBuffer.createObject(); + DeserializationError error = deserializeJson(jsonBuffer, json); + serializeJson(json, Serial); + if (!error) { + Serial.println("\nparsed json"); + + //**strcpy(mqtt_server, json["mqtt_server"]); + //**strcpy(mqtt_port, json["mqtt_port"]); + //strcpy(blynk_token, json["blynk_token"]); + + if (json["ip"]) { + Serial.print("setting custom ip from config: "); + //**strcpy(static_ip, json["ip"]); + //**strcpy(static_gw, json["gateway"]); + //**strcpy(static_sn, json["subnet"]); + Serial.println(static_ip); +/* Serial.println("converting ip"); + IPAddress ip = ipFromCharArray(static_ip); + Serial.println(ip);*/ + } else { + Serial.println("no custom ip in config"); + } + } else { + Serial.println("failed to load json config"); + } + } + } +#endif + } else { + Serial.println("failed to mount FS"); + } + //end read + Serial.print("static ip: "); Serial.println(static_ip); + // setupWifiConnection(); + + /* + radio.setClockChannel(clockChannel); + radio.setClockName(clockName); + radio.begin(); + fastclock.begin(); + pinMode(POWER_OFF_PIN, INPUT); + */ + sevenSegmentClock.begin(); +} + +int hours = 0, minutes = 0; + +void loop() { + sevenSegmentClock.displayTime(hours, minutes++); + if (minutes > 99) { minutes = 0; } + if (minutes % 5 == 0) hours++; + if (hours > 99) hours = 0; + delay(1000); +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..df5066e --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PIO Unit Testing and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PIO Unit Testing: +- https://docs.platformio.org/page/plus/unit-testing.html